lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 5537bf0e1c36a94958a4c6eceb7722845b9a4efa
parent 0aa05380aa18a504ee2758daac4853b872639541
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 13:46:22 +0000

refactor: complete authored operations v2

- retire legacy draft, atomic, and duplicate transport surfaces
- complete durable authored orchestration and migration cutover
- synchronize API, release, and coverage contract artifacts
- qualify the full library workspace and release boundary


Diffstat:
MCargo.lock | 1+
Mcontracts/events/social-events.md | 4++--
Mcontracts/operations.toml | 3++-
Mcontracts/releases/api_semver.toml | 2+-
Mcrates/event/README.md | 2+-
Mcrates/event/src/draft.rs | 1191+++++--------------------------------------------------------------------------
Mcrates/event/src/lib.rs | 2+-
Mcrates/event/src/post.rs | 17++++++++++++++++-
Mcrates/event/tests/package_boundary.rs | 2--
Mcrates/event/tests/source_boundary.rs | 4++--
Mcrates/event_codec/tests/wire.rs | 71+++++++++++++++--------------------------------------------------------
Mcrates/mesh/src/model.rs | 4++--
Dcrates/nostr/src/draft_signing.rs | 113-------------------------------------------------------------------------------
Mcrates/nostr/src/error.rs | 16----------------
Mcrates/nostr/src/lib.rs | 2--
Mcrates/nostr/src/signing.rs | 8++------
Mcrates/nostr/tests/coverage.rs | 30++++++++++++++++++++++++++++++
Mcrates/storage/src/atomic.rs | 117-------------------------------------------------------------------------------
Mcrates/storage/src/authored.rs | 477++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage/src/authored_delivery.rs | 6+++++-
Mcrates/storage/src/memory.rs | 49-------------------------------------------------
Mcrates/storage/tests/atomic.rs | 311++++++++++++++++++++++++++++++-------------------------------------------------
Mcrates/storage/tests/authored.rs | 960++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage/tests/authored_atomic.rs | 694++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage/tests/authored_delivery.rs | 579++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage/tests/conformance/suite.rs | 197+++++++++++++------------------------------------------------------------------
Mcrates/storage/tests/journal.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage/tests/memory.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/storage_sqlite/src/atomic.rs | 708++++++++-----------------------------------------------------------------------
Mcrates/storage_sqlite/src/authored.rs | 969++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/journal/mod.rs | 150++++++++++++-------------------------------------------------------------------
Mcrates/storage_sqlite/src/migration/authored_v10.rs | 317++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/outbox/mod.rs | 155-------------------------------------------------------------------------------
Mcrates/sync/src/ingest.rs | 4+---
Mcrates/sync/src/lib.rs | 6++++--
Mcrates/sync/src/projection.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sync/src/push.rs | 815++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/sync/src/status.rs | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/sync/tests/engine_composition.rs | 32++++++++++++++++++++++++++++++++
Mcrates/sync/tests/projection.rs | 38++++++++++++++++++++++++++++++++++++--
Mcrates/sync/tests/push_enqueue.rs | 1499++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Dcrates/sync/tests/reliability_scenarios.rs | 282-------------------------------------------------------------------------------
Dcrates/transport/src/delivery.rs | 635-------------------------------------------------------------------------------
Mcrates/transport/src/error.rs | 6++----
Mcrates/transport/src/id.rs | 24++++++++++++------------
Dcrates/transport/src/kind.rs | 13-------------
Mcrates/transport/src/lib.rs | 26+-------------------------
Dcrates/transport/src/payload.rs | 332-------------------------------------------------------------------------------
Mcrates/transport/src/status.rs | 356+------------------------------------------------------------------------------
Mcrates/transport/src/target.rs | 128++++++++++++++++++++++++++++++++++++++++----------------------------------------
Dcrates/transport/src/transport.rs | 41-----------------------------------------
Mcrates/transport/tests/delivery_contract.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport/tests/error_contract.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/package_boundary.rs | 2--
Mcrates/transport/tests/source_boundary.rs | 4++--
Mcrates/transport/tests/source_contract.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/target_contract.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/transport/tests/transport.rs | 1864-------------------------------------------------------------------------------
Mcrates/transport_reticulum/Cargo.toml | 1+
Mcrates/transport_reticulum/src/contract.rs | 17++++++++++-------
Mcrates/transport_reticulum/src/lib.rs | 417++++++++++++++++---------------------------------------------------------------
Mcrates/transport_reticulum/tests/reticulum.rs | 858++++++++++++++++++++++++++-----------------------------------------------------
Mdocs/api/radroots_event.txt | 157++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mdocs/api/radroots_nostr.txt | 18+++++++++++-------
Mdocs/api/radroots_signing.txt | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mdocs/api/radroots_storage.txt | 462++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mdocs/api/radroots_transport.txt | 555+++++++++++++++++++++++++++----------------------------------------------------
Mdocs/specs/radroots_crates_release_v1.md | 6+++---
Mdocs/specs/radroots_crates_release_v1.toml | 8++++++--
Mfuzz/event_codec/Cargo.lock | 2++
Mtools/xtask/src/api_qualification.rs | 11+++++++++--
Mtools/xtask/src/contract.rs | 2+-
72 files changed, 8276 insertions(+), 8857 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2895,6 +2895,7 @@ name = "radroots_transport_reticulum" version = "0.1.0-alpha" dependencies = [ "futures", + "radroots_event", "radroots_transport", "serde", "serde_json", diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -174,12 +174,12 @@ contract. Strict Profile, Update, PhotoUpdate, Ask, NIP-10 Reply, NIP-22 Comment, and NIP-09 Deletion Request contracts are `TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft contracts remain `GenericDraft`. -`EventDraft::new` therefore rejects the strict Profile contract and +`GenericEventDraft::new` therefore rejects the strict Profile contract and every governed kind-1, kind-5, or kind-1111 contract with `contract_not_draft_authorable`. Serialized drafts record registry version `7` and are accepted only after deserialization revalidates the registry version, contract, kind, shape, policy, recomputed event id, and known fields. The -frozen-draft signing boundary repeats that validation, so stale version-`1` +authored-plan signing boundary repeats that validation, so stale version-`1` through version-`6` drafts must be rebuilt. Typed root posts, Replies, Comments, and Deletion Requests enter Nostr signing only through opaque typed builders that expose timestamp selection and signing, but no raw tag/content diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -42,7 +42,8 @@ public = [ "RadrootsNostrBlossomAuthorizationHeader", "RadrootsNostrVerifiedBlossomAuthorization", "Nip01EventWireParts", - "EventDraft", + "GenericEventDraft", + "AuthoredEventPlan", "SignedEvent", "EventEnvelope", "RadrootsIdVerifiedEvent", diff --git a/contracts/releases/api_semver.toml b/contracts/releases/api_semver.toml @@ -2,7 +2,7 @@ schema_version = 1 baseline_revision = "11ade522" tool = "cargo-semver-checks" minimum_tool_version = "0.50.0" -release_type = "minor" +release_type = "major" feature_policy = "all" packages = [ "radroots_blossom", diff --git a/crates/event/README.md b/crates/event/README.md @@ -14,7 +14,7 @@ models, kinds, and tag conventions for the `radroots` core libraries. * portable event model semantics for both `std` and `no_std` builds; * optional integration with `serde` for serialization. -The crate root is intentionally limited to `Event`, `EventDraft`, +The crate root is intentionally limited to `Event`, `GenericEventDraft`, `SignedEvent`, `VerifiedEvent`, `EventId`, `EventKind`, `EventTag`, and `Error`. Domain-specific values live under their owning module. Existing focused models are nested below that authority, including `food::availability`, diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs @@ -1,23 +1,21 @@ #![forbid(unsafe_code)] #[cfg(all(not(feature = "std"), not(test)))] -use alloc::{borrow::ToOwned, string::String, vec::Vec}; +use alloc::{string::String, vec::Vec}; #[cfg(any(feature = "std", test))] -use std::{borrow::ToOwned, string::String, vec::Vec}; +use std::{string::String, vec::Vec}; -use crate::contract::registry_v7::{ - ContractValidationError, EventContract, RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - event_contract, validate_event_contract_parts, -}; -use crate::envelope::{EventEnvelope, EventEnvelopeError, EventKind, EventTags, EventTimestamp}; +use radroots_identity::PublicKey; + +use crate::contract::registry_v7::ContractValidationError; +use crate::envelope::{EventEnvelope, EventEnvelopeError}; use crate::id::{EventId, EventSignature, ParseError, parse_public_key}; use crate::wire::v1::{ CanonicalEventIdError, EventWireError, Nip01EventWire, canonical_nip01_event_id_preimage, compute_canonical_nip01_event_id, }; use core::fmt; -use radroots_identity::PublicKey; #[derive(Clone, Debug, PartialEq, Eq)] pub enum DraftError { @@ -42,34 +40,6 @@ pub enum DraftError { contract_id: String, error: ContractValidationError, }, - SignedEventPubkeyMismatch { - expected_pubkey: String, - actual_pubkey: String, - }, - SignedEventIdMismatch { - expected_event_id: String, - actual_event_id: String, - }, - SignedEventCreatedAtMismatch { - expected_created_at: u64, - actual_created_at: u64, - }, - SignedEventKindMismatch { - expected_kind: u32, - actual_kind: u32, - }, - SignedEventTagsMismatch { - expected_len: usize, - actual_len: usize, - }, - SignedEventContentMismatch { - expected_len: usize, - actual_len: usize, - }, - SignedEventComputedIdMismatch { - expected_event_id: String, - computed_event_id: String, - }, IdParse(ParseError), CanonicalEventId(CanonicalEventIdError), Envelope(EventEnvelopeError), @@ -110,55 +80,6 @@ impl fmt::Display for DraftError { "event contract `{contract_id}` shape validation failed with code {}", error.code() ), - Self::SignedEventPubkeyMismatch { - expected_pubkey, - actual_pubkey, - } => write!( - f, - "signed event pubkey mismatch: expected {expected_pubkey}, got {actual_pubkey}" - ), - Self::SignedEventIdMismatch { - expected_event_id, - actual_event_id, - } => write!( - f, - "signed event id mismatch: expected {expected_event_id}, got {actual_event_id}" - ), - Self::SignedEventCreatedAtMismatch { - expected_created_at, - actual_created_at, - } => write!( - f, - "signed event created_at mismatch: expected {expected_created_at}, got {actual_created_at}" - ), - Self::SignedEventKindMismatch { - expected_kind, - actual_kind, - } => write!( - f, - "signed event kind mismatch: expected {expected_kind}, got {actual_kind}" - ), - Self::SignedEventTagsMismatch { - expected_len, - actual_len, - } => write!( - f, - "signed event tags mismatch: expected {expected_len} tags, got {actual_len} tags" - ), - Self::SignedEventContentMismatch { - expected_len, - actual_len, - } => write!( - f, - "signed event content mismatch: expected {expected_len} bytes, got {actual_len} bytes" - ), - Self::SignedEventComputedIdMismatch { - expected_event_id, - computed_event_id, - } => write!( - f, - "signed event computed id mismatch: expected {expected_event_id}, computed {computed_event_id}" - ), Self::IdParse(error) => write!(f, "{error}"), Self::CanonicalEventId(error) => write!(f, "{error}"), Self::Envelope(error) => write!(f, "{error}"), @@ -197,247 +118,6 @@ impl From<SignedEventError> for DraftError { } } -#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct EventDraft { - contract_id: String, - contract_registry_version: u32, - kind: EventKind, - created_at: EventTimestamp, - tags: EventTags, - content: String, - expected_pubkey: PublicKey, - expected_event_id: EventId, -} - -impl EventDraft { - pub fn new( - contract_id: impl Into<String>, - kind: u32, - created_at: u64, - tags: Vec<Vec<String>>, - content: impl Into<String>, - expected_pubkey: impl AsRef<str>, - ) -> Result<Self, DraftError> { - let contract_id = contract_id.into(); - let contract = match event_contract(&contract_id) { - Some(contract) => contract, - None => return Err(DraftError::UnknownContract(contract_id.clone())), - }; - crate::require_invariant(contract.kind == kind, &|| { - DraftError::ContractKindMismatch { - contract_id: contract_id.clone(), - expected_kind: contract.kind, - actual_kind: kind, - } - })?; - ensure_generic_draft_authorable(contract)?; - let expected_pubkey = parse_public_key(expected_pubkey.as_ref())?; - let content = content.into(); - validate_event_contract_parts(kind, &tags, content.as_str(), contract.id).map_err( - |error| DraftError::ContractShape { - contract_id: contract.id.to_owned(), - error, - }, - )?; - let typed_tags = EventTags::new(tags)?; - let expected_pubkey_hex = expected_pubkey.to_hex(); - let expected_event_id = compute_nip01_event_id_for_valid_pubkey( - expected_pubkey_hex.as_str(), - created_at, - kind, - &typed_tags.to_vec(), - &content, - ); - Ok(Self { - contract_id: contract.id.to_owned(), - contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - kind: EventKind::new(kind), - created_at: EventTimestamp::new(created_at), - tags: typed_tags, - content, - expected_pubkey, - expected_event_id, - }) - } - - pub fn nip01_preimage(&self) -> Result<String, DraftError> { - let expected_pubkey = self.expected_pubkey.to_hex(); - Ok(nip01_event_id_preimage_for_valid_pubkey( - expected_pubkey.as_str(), - self.created_at.as_u64(), - self.kind.as_u32(), - &self.tags.to_vec(), - self.content.as_str(), - )) - } - - /// Revalidates registry policy, contract shape, and the deterministic ID. - /// - /// Signing boundaries must call this even for a previously validated draft - /// so persisted data cannot bypass current registry authority. - pub fn validate_for_signing(&self) -> Result<(), DraftError> { - crate::require_invariant( - self.contract_registry_version == RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - &|| DraftError::ContractRegistryVersionMismatch { - expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - actual: self.contract_registry_version, - }, - )?; - let contract = event_contract(self.contract_id()) - .ok_or_else(|| DraftError::UnknownContract(self.contract_id().to_owned()))?; - crate::require_invariant(contract.kind == self.kind_u32(), &|| { - DraftError::ContractKindMismatch { - contract_id: contract.id.to_owned(), - expected_kind: contract.kind, - actual_kind: self.kind_u32(), - } - })?; - ensure_generic_draft_authorable(contract)?; - validate_event_contract_parts( - self.kind_u32(), - &self.tags_as_vec(), - self.content(), - contract.id, - ) - .map_err(|error| DraftError::ContractShape { - contract_id: contract.id.to_owned(), - error, - })?; - let expected_pubkey = self.expected_pubkey.to_hex(); - let actual_event_id = compute_nip01_event_id_for_valid_pubkey( - expected_pubkey.as_str(), - self.created_at_u64(), - self.kind_u32(), - &self.tags_as_vec(), - self.content(), - ); - crate::require_invariant(actual_event_id == self.expected_event_id, &|| { - DraftError::DraftExpectedEventIdMismatch { - expected_event_id: actual_event_id.to_hex(), - actual_event_id: self.expected_event_id.to_hex(), - } - })?; - Ok(()) - } - - #[inline] - pub fn contract_id(&self) -> &str { - self.contract_id.as_str() - } - - #[inline] - pub fn contract_registry_version(&self) -> u32 { - self.contract_registry_version - } - - #[inline] - pub fn kind(&self) -> EventKind { - self.kind - } - - #[inline] - pub fn kind_u32(&self) -> u32 { - self.kind.as_u32() - } - - #[inline] - pub fn created_at(&self) -> EventTimestamp { - self.created_at - } - - #[inline] - pub fn created_at_u64(&self) -> u64 { - self.created_at.as_u64() - } - - #[inline] - pub fn tags(&self) -> &EventTags { - &self.tags - } - - pub fn tags_as_vec(&self) -> Vec<Vec<String>> { - self.tags.to_vec() - } - - #[inline] - pub fn content(&self) -> &str { - self.content.as_str() - } - - #[inline] - pub fn expected_pubkey(&self) -> &PublicKey { - &self.expected_pubkey - } - - #[inline] - pub fn expected_event_id(&self) -> &EventId { - &self.expected_event_id - } - - #[inline] - pub fn expected_event_id_hex(&self) -> String { - self.expected_event_id.to_hex() - } -} - -fn ensure_generic_draft_authorable(contract: &EventContract) -> Result<(), DraftError> { - crate::require_invariant(contract.authoring_policy().permits_generic_draft(), &|| { - DraftError::ContractNotDraftAuthorable { - contract_id: contract.id.to_owned(), - } - }) -} - -#[cfg(any(feature = "serde", test))] -impl<'de> serde::Deserialize<'de> for EventDraft { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - #[derive(serde::Deserialize)] - #[serde(deny_unknown_fields)] - struct DraftSerde { - contract_id: String, - contract_registry_version: u32, - kind: EventKind, - created_at: EventTimestamp, - tags: EventTags, - content: String, - expected_pubkey: PublicKey, - expected_event_id: EventId, - } - - let value = DraftSerde::deserialize(deserializer)?; - if value.contract_registry_version != RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION { - return Err(serde::de::Error::custom( - DraftError::ContractRegistryVersionMismatch { - expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - actual: value.contract_registry_version, - }, - )); - } - let draft = Self::new( - value.contract_id, - value.kind.as_u32(), - value.created_at.as_u64(), - value.tags.to_vec(), - value.content, - value.expected_pubkey.to_hex(), - ) - .map_err(serde::de::Error::custom)?; - if draft.expected_event_id != value.expected_event_id { - return Err(serde::de::Error::custom( - DraftError::DraftExpectedEventIdMismatch { - expected_event_id: draft.expected_event_id.to_hex(), - actual_event_id: value.expected_event_id.to_hex(), - }, - )); - } - Ok(draft) - } -} - #[cfg_attr( any(feature = "serde", test), derive(serde::Serialize, serde::Deserialize) @@ -571,22 +251,6 @@ impl SignedEvent { }) } - #[cfg(test)] - fn from_wire_unchecked( - wire: Nip01EventWire, - raw_json: impl Into<String>, - ) -> Result<Self, SignedEventError> { - let envelope = wire - .clone() - .into_unverified_envelope() - .map_err(SignedEventError::Envelope)?; - Ok(Self { - envelope, - wire, - raw_json: raw_json.into(), - }) - } - #[inline] pub fn envelope(&self) -> &EventEnvelope { &self.envelope @@ -659,65 +323,6 @@ impl SignedEvent { } } -pub fn validate_signed_nostr_event_matches_draft( - signed_event: &SignedEvent, - draft: &EventDraft, -) -> Result<(), DraftError> { - draft.validate_for_signing()?; - crate::require_invariant(signed_event.pubkey() == draft.expected_pubkey(), &|| { - DraftError::SignedEventPubkeyMismatch { - expected_pubkey: draft.expected_pubkey().to_hex(), - actual_pubkey: signed_event.pubkey().to_hex(), - } - })?; - crate::require_invariant(signed_event.created_at() == draft.created_at_u64(), &|| { - DraftError::SignedEventCreatedAtMismatch { - expected_created_at: draft.created_at_u64(), - actual_created_at: signed_event.created_at(), - } - })?; - crate::require_invariant(signed_event.kind() == draft.kind_u32(), &|| { - DraftError::SignedEventKindMismatch { - expected_kind: draft.kind_u32(), - actual_kind: signed_event.kind(), - } - })?; - let signed_tags = signed_event.tags_as_vec(); - let draft_tags = draft.tags_as_vec(); - crate::require_invariant(signed_tags == draft_tags, &|| { - DraftError::SignedEventTagsMismatch { - expected_len: draft_tags.len(), - actual_len: signed_tags.len(), - } - })?; - crate::require_invariant(signed_event.content() == draft.content(), &|| { - DraftError::SignedEventContentMismatch { - expected_len: draft.content().len(), - actual_len: signed_event.content().len(), - } - })?; - crate::require_invariant(signed_event.id() == draft.expected_event_id(), &|| { - DraftError::SignedEventIdMismatch { - expected_event_id: draft.expected_event_id.to_hex(), - actual_event_id: signed_event.id().to_hex(), - } - })?; - let signed_pubkey = signed_event.pubkey().to_hex(); - let computed_event_id = compute_nip01_event_id_for_valid_pubkey( - signed_pubkey.as_str(), - draft.created_at_u64(), - signed_event.kind(), - &signed_tags, - signed_event.content(), - ); - crate::require_invariant(computed_event_id == *signed_event.id(), &|| { - DraftError::SignedEventComputedIdMismatch { - expected_event_id: signed_event.id().to_hex(), - computed_event_id: computed_event_id.to_hex(), - } - }) -} - pub fn compute_nip01_event_id( pubkey: &str, created_at: u64, @@ -772,10 +377,7 @@ fn nip01_event_id_preimage_for_valid_pubkey( #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; - use crate::envelope::kind::{ - KIND_COMMENT, KIND_DELETION_REQUEST, KIND_FARM_CRDT_CHANGE, KIND_GEOCHAT, - KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST, KIND_PROFILE, - }; + use crate::envelope::kind::KIND_POST; fn hex_64(character: char) -> String { crate::test_valid_hex_64(character) @@ -785,6 +387,23 @@ mod tests { core::iter::repeat_n(character, 128).collect() } + fn verified_wire(content: &str) -> Nip01EventWire { + let pubkey = hex_64('a'); + let id = compute_canonical_nip01_event_id(&pubkey, 1_700_000_000, KIND_POST, &[], content) + .expect("event id") + .into_string(); + Nip01EventWire { + id, + pubkey, + created_at: 1_700_000_000, + kind: KIND_POST, + tags: Vec::new(), + content: content.to_owned(), + sig: hex_128('b'), + extra: Default::default(), + } + } + fn raw_json_for_wire(wire: &Nip01EventWire) -> String { serde_json::to_string(&serde_json::json!({ "id": wire.id, @@ -795,757 +414,99 @@ mod tests { "content": wire.content, "sig": wire.sig, })) - .expect("raw json") - } - - fn verified_wire( - pubkey: String, - created_at: u64, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - sig: String, - ) -> Nip01EventWire { - let id = compute_canonical_nip01_event_id( - pubkey.as_str(), - created_at, - kind, - &tags, - content.as_str(), - ) - .expect("event id") - .into_string(); - Nip01EventWire { - id, - pubkey, - created_at, - kind, - tags, - content, - sig, - extra: Default::default(), - } - } - - fn unchecked_wire( - id: String, - pubkey: String, - created_at: u64, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - sig: String, - ) -> Nip01EventWire { - Nip01EventWire { - id, - pubkey, - created_at, - kind, - tags, - content, - sig, - extra: Default::default(), - } - } - - fn signed_event_for_draft(draft: &EventDraft) -> SignedEvent { - let wire = verified_wire( - draft.expected_pubkey().to_hex(), - draft.created_at_u64(), - draft.kind_u32(), - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ); - let raw_json = raw_json_for_wire(&wire); - SignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") - } - - fn generic_draft() -> EventDraft { - EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, - Vec::new(), - "hello", - "a".repeat(64), - ) - .expect("draft") - } - - fn claim_content() -> &'static str { - r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"# - } - - #[test] - fn draft_computes_expected_event_id() { - let draft = EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, - Vec::new(), - "hello", - hex_64('a'), - ) - .expect("draft"); - - assert_eq!( - draft.nip01_preimage().expect("preimage"), - "[0,\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",1700000000,20000,[],\"hello\"]" - ); - assert_eq!( - draft.expected_event_id_hex(), - "07643222c33091b20114d5baf1a32288e808177eac7d87acb2c4f610363a2a7d" - ); - } - - #[test] - fn deterministic_event_id_changes_when_preimage_changes() { - let tags = vec![vec!["t".to_owned(), "soil".to_owned()]]; - let base = compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_POST, &tags, "hello") - .expect("base"); - let pubkey_changed = - compute_nip01_event_id(hex_64('b').as_str(), 1, KIND_POST, &tags, "hello") - .expect("pubkey"); - let time_changed = - compute_nip01_event_id(hex_64('a').as_str(), 2, KIND_POST, &tags, "hello") - .expect("time"); - let kind_changed = - compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_PROFILE, &tags, "hello") - .expect("kind"); - let tag_order_changed = compute_nip01_event_id( - hex_64('a').as_str(), - 1, - KIND_POST, - &[ - vec!["p".to_owned(), hex_64('c')], - vec!["t".to_owned(), "soil".to_owned()], - ], - "hello", - ) - .expect("tag order"); - let content_changed = - compute_nip01_event_id(hex_64('a').as_str(), 1, KIND_POST, &tags, "hello!") - .expect("content"); - - assert_ne!(base, pubkey_changed); - assert_ne!(base, time_changed); - assert_ne!(base, kind_changed); - assert_ne!(base, tag_order_changed); - assert_ne!(base, content_changed); - } - - #[test] - fn profile_golden_event_id_is_stable() { - let event_id = compute_nip01_event_id(hex_64('c').as_str(), 1_700_000_001, 0, &[], "{}") - .expect("event id"); - - assert_eq!( - event_id.to_hex(), - "679ee570a933961d4f5ee95ed60cbc34d85c9c24a8da5e92f6036462ee0fc852" - ); + .expect("raw JSON") } #[test] - fn draft_constructor_rejects_unknown_contract_and_kind_mismatch() { - let unknown = EventDraft::new("missing", KIND_POST, 1, Vec::new(), "", hex_64('a')) - .expect_err("unknown contract"); - assert!(matches!(unknown, DraftError::UnknownContract(_))); - - let mismatch = EventDraft::new( - "radroots.social.post.v1", - KIND_PROFILE, - 1, - Vec::new(), - "", - hex_64('a'), - ) - .expect_err("kind mismatch"); - assert!(matches!(mismatch, DraftError::ContractKindMismatch { .. })); - - let invalid_pubkey = EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1, - Vec::new(), - "", - "not-hex", - ) - .expect_err("invalid pubkey"); - assert!(matches!(invalid_pubkey, DraftError::IdParse(_))); - } - - #[test] - fn draft_constructor_rejects_read_only_and_typed_only_contracts() { - for (contract_id, kind) in [ - ("radroots.profile.metadata.v1", KIND_PROFILE), - ("radroots.social.post.v1", KIND_POST), - ("radroots.social.update.v1", KIND_POST), - ("radroots.social.photo_update.v1", KIND_POST), - ("radroots.social.ask.v1", KIND_POST), - ("radroots.social.reply.v1", KIND_POST), - ("radroots.social.deletion_request.v1", KIND_DELETION_REQUEST), - ("radroots.social.comment.v1", KIND_COMMENT), - ] { - let error = EventDraft::new(contract_id, kind, 1, Vec::new(), "hello", hex_64('a')) - .expect_err("governed typed or read-only contract must reject generic drafts"); - assert_eq!( - error, - DraftError::ContractNotDraftAuthorable { - contract_id: contract_id.to_owned(), - } - ); - } - } - - #[test] - fn draft_deserialization_revalidates_registry_policy_shape_and_event_id() { - let draft = generic_draft(); - let value = serde_json::to_value(&draft).expect("draft json"); - let decoded: EventDraft = - serde_json::from_value(value.clone()).expect("validated roundtrip"); - assert_eq!(decoded, draft); - - for stale_version in 1..RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION { - let mut tampered = value.clone(); - tampered["contract_registry_version"] = serde_json::json!(stale_version); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("stale registry generation must fail"); - assert!(error.to_string().contains("registry version mismatch")); - } - - let mut tampered = value.clone(); - tampered["expected_event_id"] = serde_json::Value::String(hex_64('f')); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("tampered deterministic ID must fail"); - assert!(error.to_string().contains("frozen draft event ID mismatch")); - - let mut tampered = value.clone(); - tampered["contract_id"] = serde_json::Value::String("missing".to_owned()); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("unknown persisted contract must fail"); - assert!(error.to_string().contains("unknown event contract")); - - let mut tampered = value.clone(); - tampered["kind"] = serde_json::json!(KIND_PROFILE); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("persisted contract kind mismatch must fail"); - assert!(error.to_string().contains("expects kind")); - - let mut tampered = value.clone(); - tampered["contract_id"] = serde_json::Value::String("radroots.social.post.v1".to_owned()); - tampered["kind"] = serde_json::json!(KIND_POST); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("read-only persisted contract must fail"); - assert!(error.to_string().contains("not authorable")); - - let json_draft = EventDraft::new( - "radroots.farm.crdt_change.v1", - KIND_FARM_CRDT_CHANGE, - 1, - Vec::new(), - "{}", - hex_64('a'), - ) - .expect("generic JSON draft"); - let mut tampered = serde_json::to_value(json_draft).expect("JSON draft value"); - tampered["content"] = serde_json::Value::String("not-json".to_owned()); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("persisted contract shape mismatch must fail"); - assert!(error.to_string().contains("shape validation failed")); - - let mut tampered = value; - tampered["unexpected"] = serde_json::json!(true); - let error = serde_json::from_value::<EventDraft>(tampered) - .expect_err("unknown persisted draft field must fail"); - assert!(error.to_string().contains("unknown field")); - } - - #[test] - fn draft_constructor_rejects_contract_shape_errors() { - let missing_contract = EventDraft::new( - "radroots.knowledge.claim.v1", - KIND_KNOWLEDGE_CLAIM, - 1, - Vec::new(), - claim_content(), - hex_64('a'), - ) - .expect_err("missing contract tag"); - assert!(matches!( - missing_contract, - DraftError::ContractShape { - error: ContractValidationError::MissingTag { - name: "contract", - .. - }, - .. - } - )); - - let invalid_event_pointer = EventDraft::new( - "radroots.knowledge.claim.v1", - KIND_KNOWLEDGE_CLAIM, - 1, - vec![ - vec![ - "contract".to_owned(), - "radroots.knowledge.claim.v1".to_owned(), - ], - vec![ - "source".to_owned(), - "not-hex".to_owned(), - hex_64('a'), - KIND_KNOWLEDGE_SOURCE.to_string(), - String::new(), - ], - ], - claim_content(), - hex_64('a'), - ) - .expect_err("invalid event pointer"); - assert!(matches!( - invalid_event_pointer, - DraftError::ContractShape { - error: ContractValidationError::TagValueMismatch { name: "source", .. }, - .. - } - )); - - let invalid_relay = EventDraft::new( - "radroots.knowledge.claim.v1", - KIND_KNOWLEDGE_CLAIM, - 1, - vec![ - vec![ - "contract".to_owned(), - "radroots.knowledge.claim.v1".to_owned(), - ], - vec![ - "source".to_owned(), - hex_64('b'), - hex_64('a'), - KIND_KNOWLEDGE_SOURCE.to_string(), - String::new(), - "http://relay.radroots.example".to_owned(), - ], - ], - claim_content(), - hex_64('a'), - ) - .expect_err("invalid event pointer relay"); - assert!(matches!( - invalid_relay, - DraftError::ContractShape { - error: ContractValidationError::TagValueMismatch { name: "source", .. }, - .. - } - )); - - let invalid_json = EventDraft::new( - "radroots.knowledge.claim.v1", - KIND_KNOWLEDGE_CLAIM, - 1, - vec![vec![ - "contract".to_owned(), - "radroots.knowledge.claim.v1".to_owned(), - ]], - "not-json", - hex_64('a'), - ) - .expect_err("invalid json"); - assert!(matches!( - invalid_json, - DraftError::ContractShape { - error: ContractValidationError::InvalidJsonContent { .. }, - .. - } - )); - } - - #[test] - fn signed_event_validates_ids_and_roundtrips_with_serde() { - let wire = verified_wire( - hex_64('e'), - 10, - KIND_POST, - vec![vec!["t".to_owned(), "soil".to_owned()]], - "hello".to_owned(), - hex_128('f'), - ); + fn signed_event_preserves_verified_wire_and_typed_accessors() { + let wire = verified_wire("hello"); let raw_json = raw_json_for_wire(&wire); - let signed = SignedEvent::new(SignedEventParts { - id: wire.id.clone(), - pubkey: wire.pubkey.clone(), - created_at: wire.created_at, - kind: wire.kind, - tags: wire.tags.clone(), - content: wire.content.clone(), - sig: wire.sig.clone(), - raw_json: raw_json.clone(), - }) - .expect("signed event"); - let json = serde_json::to_string(&signed).expect("serialize"); - let decoded: SignedEvent = serde_json::from_str(&json).expect("deserialize"); + let signed = SignedEvent::from_wire_verified_id(wire.clone(), raw_json.clone()) + .expect("signed event"); - assert_eq!(decoded, signed); - assert_eq!(decoded.envelope().id_hex(), decoded.id_hex()); - assert_eq!(decoded.wire().id, decoded.id_hex()); - assert_eq!(decoded.id().to_hex(), decoded.id_hex()); - assert_eq!(decoded.pubkey().to_hex(), hex_64('e')); - assert_eq!(decoded.created_at(), 10); - assert_eq!(decoded.kind(), KIND_POST); - assert_eq!(decoded.tags_as_vec(), wire.tags); - assert_eq!(decoded.content(), "hello"); - assert_eq!(decoded.sig().to_hex(), decoded.signature_hex()); - assert_eq!(decoded.raw_json(), raw_json); + assert_eq!(signed.wire(), &wire); + assert_eq!(signed.raw_json(), raw_json); + assert_eq!(signed.envelope().id_hex(), signed.id_hex()); + assert_eq!(signed.id().to_hex(), signed.id_hex()); + assert_eq!(signed.pubkey().to_hex(), wire.pubkey); + assert_eq!(signed.created_at(), wire.created_at); + assert_eq!(signed.kind(), wire.kind); + assert_eq!(signed.tags_as_vec(), wire.tags); + assert_eq!(signed.content(), wire.content); + assert_eq!(signed.sig().to_hex(), signed.signature_hex()); + assert_eq!(signed.sig_str(), wire.sig); } #[test] - fn signed_event_constructors_validate_wire_and_raw_json() { - let wire = verified_wire( - hex_64('2'), - 42, - KIND_POST, - vec![vec!["t".to_owned(), "soil".to_owned()]], - "hello".to_owned(), - hex_128('3'), - ); + fn signed_event_rejects_noncanonical_or_mismatched_wire() { + let wire = verified_wire("hello"); let raw_json = raw_json_for_wire(&wire); - let signed = SignedEvent::from_wire_verified_id(wire.clone(), raw_json.clone()) - .expect("signed event"); + let mut different_wire = verified_wire("different"); + let error = + SignedEvent::from_wire_verified_id(wire.clone(), raw_json_for_wire(&different_wire)) + .expect_err("raw JSON mismatch"); + assert_eq!(error, SignedEventError::RawJsonMismatch); - assert_eq!(signed.id_hex(), wire.id); - assert_eq!(signed.pubkey().to_hex(), hex_64('2')); - assert_eq!(signed.signature_hex(), hex_128('3')); - assert_eq!(signed.raw_json(), raw_json); + different_wire.id = hex_64('c'); + let error = SignedEvent::from_wire_verified_id( + different_wire.clone(), + raw_json_for_wire(&different_wire), + ) + .expect_err("event id mismatch"); + assert!(matches!(error, SignedEventError::Wire(_))); - let invalid = SignedEvent::new(SignedEventParts { + let error = SignedEvent::new(SignedEventParts { id: "not-hex".to_owned(), - pubkey: hex_64('e'), - created_at: 10, + pubkey: hex_64('a'), + created_at: 1, kind: KIND_POST, tags: Vec::new(), content: String::new(), - sig: "f".repeat(128), - raw_json: "{}".to_owned(), + sig: hex_128('b'), + raw_json, }) .expect_err("invalid id"); assert!(matches!( - invalid, + error, SignedEventError::Envelope(EventEnvelopeError::InvalidId(_)) )); - - let invalid = SignedEvent::new(SignedEventParts { - id: hex_64('d'), - pubkey: "not-hex".to_owned(), - created_at: 10, - kind: KIND_POST, - tags: Vec::new(), - content: String::new(), - sig: "f".repeat(128), - raw_json: "{}".to_owned(), - }) - .expect_err("invalid pubkey"); - assert!(matches!( - invalid, - SignedEventError::Envelope(EventEnvelopeError::InvalidAuthor(_)) - )); - - let invalid = SignedEvent::new(SignedEventParts { - id: hex_64('d'), - pubkey: hex_64('e'), - created_at: 10, - kind: KIND_POST, - tags: Vec::new(), - content: String::new(), - sig: "not-hex".to_owned(), - raw_json: "{}".to_owned(), - }) - .expect_err("invalid sig"); - assert!(matches!( - invalid, - SignedEventError::Envelope(EventEnvelopeError::InvalidSignature(_)) - )); - - let mismatched_raw = - SignedEvent::from_wire_verified_id(wire, "{}").expect_err("raw mismatch"); - assert!(matches!(mismatched_raw, SignedEventError::RawJson(_))); - } - - #[test] - fn signed_event_validation_accepts_exact_draft_match() { - let draft = generic_draft(); - let signed = signed_event_for_draft(&draft); - - validate_signed_nostr_event_matches_draft(&signed, &draft).expect("valid signed event"); } #[test] - fn signed_event_validation_rejects_draft_mismatches() { - let draft = generic_draft(); + fn event_id_helpers_are_canonical_and_reject_invalid_pubkeys() { + let expected = "bb46df8e0d14e08773c7c6c88dfbb0925e6432048a2f2e82592afa415462d62a"; + let event_id = compute_nip01_event_id(&hex_64('a'), 1_700_000_000, KIND_POST, &[], "hello") + .expect("event id"); + assert_eq!(event_id.to_hex(), expected); + assert_eq!( + nip01_event_id_preimage(&hex_64('a'), 1_700_000_000, KIND_POST, &[], "hello") + .expect("preimage"), + "[0,\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",1700000000,1,[],\"hello\"]" + ); - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_hex(), - hex_64('c'), - draft.created_at_u64(), - draft.kind_u32(), - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); assert!(matches!( - error, - DraftError::SignedEventPubkeyMismatch { .. } + compute_nip01_event_id("not-hex", 1, KIND_POST, &[], ""), + Err(DraftError::IdParse(_)) )); - - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - hex_64('d'), - draft.expected_pubkey().to_hex(), - draft.created_at_u64(), - draft.kind_u32(), - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); - assert!(matches!(error, DraftError::SignedEventIdMismatch { .. })); - - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_hex(), - draft.expected_pubkey().to_hex(), - draft.created_at_u64() + 1, - draft.kind_u32(), - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); assert!(matches!( - error, - DraftError::SignedEventCreatedAtMismatch { .. } - )); - - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_hex(), - draft.expected_pubkey().to_hex(), - draft.created_at_u64(), - KIND_PROFILE, - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); - assert!(matches!(error, DraftError::SignedEventKindMismatch { .. })); - - let mut tags = draft.tags_as_vec(); - tags.push(vec!["p".to_owned(), hex_64('e')]); - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_hex(), - draft.expected_pubkey().to_hex(), - draft.created_at_u64(), - draft.kind_u32(), - tags, - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); - assert!(matches!(error, DraftError::SignedEventTagsMismatch { .. })); - - let signed = SignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_hex(), - draft.expected_pubkey().to_hex(), - draft.created_at_u64(), - draft.kind_u32(), - draft.tags_as_vec(), - "changed".to_owned(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); - assert!(matches!( - error, - DraftError::SignedEventContentMismatch { .. } + nip01_event_id_preimage("not-hex", 1, KIND_POST, &[], ""), + Err(DraftError::IdParse(_)) )); } #[test] - fn draft_errors_format_all_variants() { + fn errors_have_stable_nonempty_messages() { let errors = [ DraftError::UnknownContract("missing".to_owned()), - DraftError::ContractKindMismatch { - contract_id: "radroots.social.post.v1".to_owned(), - expected_kind: KIND_POST, - actual_kind: KIND_PROFILE, - }, DraftError::ContractNotDraftAuthorable { - contract_id: "radroots.social.post.v1".to_owned(), - }, - DraftError::ContractRegistryVersionMismatch { - expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, - actual: 1, - }, - DraftError::DraftExpectedEventIdMismatch { - expected_event_id: hex_64('a'), - actual_event_id: hex_64('b'), - }, - DraftError::ContractShape { - contract_id: "radroots.knowledge.claim.v1".to_owned(), - error: ContractValidationError::MissingTag { - contract_id: "radroots.knowledge.claim.v1", - name: "contract", - }, - }, - DraftError::SignedEventPubkeyMismatch { - expected_pubkey: hex_64('a'), - actual_pubkey: hex_64('b'), - }, - DraftError::SignedEventIdMismatch { - expected_event_id: hex_64('c'), - actual_event_id: hex_64('d'), - }, - DraftError::SignedEventCreatedAtMismatch { - expected_created_at: 1, - actual_created_at: 2, - }, - DraftError::SignedEventKindMismatch { - expected_kind: KIND_POST, - actual_kind: KIND_PROFILE, - }, - DraftError::SignedEventTagsMismatch { - expected_len: 1, - actual_len: 2, - }, - DraftError::SignedEventContentMismatch { - expected_len: 5, - actual_len: 7, - }, - DraftError::SignedEventComputedIdMismatch { - expected_event_id: hex_64('e'), - computed_event_id: hex_64('f'), + contract_id: "typed-only".to_owned(), }, DraftError::from(ParseError::Empty), - DraftError::CanonicalEventId(CanonicalEventIdError::InvalidComputedEventId( - ParseError::InvalidFormat, - )), DraftError::from(EventEnvelopeError::NonCanonicalId), DraftError::from(SignedEventError::RawJsonMismatch), ]; - for error in errors { assert!(!error.to_string().is_empty()); } - - assert!( - DraftError::CanonicalEventId(CanonicalEventIdError::InvalidComputedEventId( - ParseError::InvalidFormat, - ),) - .to_string() - .contains("canonical event id digest") - ); - - assert!(matches!( - DraftError::from(CanonicalEventIdError::InvalidPubkey( - ParseError::InvalidFormat, - )), - DraftError::IdParse(_) - )); - assert!(matches!( - DraftError::from(CanonicalEventIdError::InvalidComputedEventId( - ParseError::InvalidFormat, - )), - DraftError::CanonicalEventId(_) - )); - } - - #[test] - #[cfg_attr(coverage_nightly, coverage(off))] - fn draft_and_signed_event_accessors_expose_typed_state() { - let draft = generic_draft(); - assert_eq!(draft.contract_id(), "radroots.social.geochat.v1"); - assert_eq!( - draft.contract_registry_version(), - RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION - ); - assert_eq!(draft.kind().as_u32(), draft.kind_u32()); - assert_eq!(draft.created_at().as_u64(), draft.created_at_u64()); - assert_eq!(draft.tags().to_vec(), draft.tags_as_vec()); - assert_eq!(draft.expected_pubkey().to_hex(), hex_64('a')); - assert_eq!( - draft.expected_event_id().to_hex(), - draft.expected_event_id_hex() - ); - - let signed = signed_event_for_draft(&draft); - assert_eq!(signed.envelope().id_hex(), signed.id_hex()); - assert_eq!(signed.wire().id, signed.id_hex()); - assert_eq!(signed.id().to_hex(), signed.id_hex()); - assert_eq!(signed.pubkey(), draft.expected_pubkey()); - assert_eq!(signed.sig().to_hex(), signed.signature_hex()); - - for error in [ - SignedEventError::Wire(EventWireError::NonCanonicalIdentifier { field: "id" }), - SignedEventError::RawJson(EventWireError::NonCanonicalIdentifier { field: "id" }), - SignedEventError::RawJsonMismatch, - SignedEventError::from(EventEnvelopeError::NonCanonicalId), - ] { - assert!(!error.to_string().is_empty()); - } - - let wire = signed.wire().clone(); - let mut different_wire = wire.clone(); - different_wire.content = "different".to_owned(); - different_wire.id = compute_canonical_nip01_event_id( - different_wire.pubkey.as_str(), - different_wire.created_at, - different_wire.kind, - &different_wire.tags, - different_wire.content.as_str(), - ) - .expect("different id") - .into_string(); - let error = SignedEvent::from_wire_verified_id(wire, raw_json_for_wire(&different_wire)) - .expect_err("raw JSON mismatch"); - assert_eq!(error, SignedEventError::RawJsonMismatch); - } - - #[test] - fn event_id_computation_rejects_invalid_pubkeys() { - let error = - compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey"); - assert!(matches!(error, DraftError::IdParse(_))); - let error = nip01_event_id_preimage("not-hex", 1, KIND_POST, &[], "") - .expect_err("invalid preimage pubkey"); - assert!(matches!(error, DraftError::IdParse(_))); } } diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -65,7 +65,7 @@ pub mod trade; mod verification; pub mod wire; -pub use draft::{EventDraft, SignedEvent}; +pub use draft::SignedEvent; pub use envelope::{EventEnvelope as Event, EventKind, EventTag}; pub use generic_draft::GenericEventDraft; pub use id::EventId; diff --git a/crates/event/src/post.rs b/crates/event/src/post.rs @@ -42,7 +42,6 @@ pub enum AuthoredPostError { ImageMissing, ImageCountExceeded { max: usize, actual: usize }, ImageUrlOccurrenceCount { expected: usize, actual: usize }, - ImageUrlOverlap, DuplicateImageUrl, ImageMediaTypeInvalid, ImageSizeInvalid, @@ -53,6 +52,7 @@ pub enum AuthoredPostError { TagElementTooLarge { max: usize, actual: usize }, TagBytesExceeded { max: usize, actual: usize }, EventWireTooLarge { max: usize, actual: usize }, + ImageUrlOverlap, } impl AuthoredPostError { @@ -599,6 +599,7 @@ mod tests { for valid in [ "image/png", "image/1", + "image/a0", "image/vnd.radroots+png", "image/x-radroots.photo", ] { @@ -767,6 +768,11 @@ mod tests { AuthoredPostImage::new(empty, dimensions, "empty").unwrap_err(), AuthoredPostError::ImageSizeInvalid ); + let noncanonical_media = authored_image(b"x", "image/p_ng", "png", "media.example"); + assert_eq!( + AuthoredPostImage::new(noncanonical_media, dimensions, "photo").unwrap_err(), + AuthoredPostError::ImageMediaTypeInvalid + ); let valid = authored_image(b"x", "image/png", "png", "media.example"); assert_eq!( AuthoredPostImage::new(valid.clone(), dimensions, " \t").unwrap_err(), @@ -808,6 +814,13 @@ mod tests { .unwrap(); let too_many = vec![image.clone(); RADROOTS_POST_IMETA_MAX_COUNT + 1]; assert_eq!( + AuthoredPhotoUpdate::new("photo", too_many.clone()).unwrap_err(), + AuthoredPostError::ImageCountExceeded { + max: RADROOTS_POST_IMETA_MAX_COUNT, + actual: RADROOTS_POST_IMETA_MAX_COUNT + 1, + } + ); + assert_eq!( AuthoredAsk::new("ask", too_many).unwrap_err(), AuthoredPostError::ImageCountExceeded { max: RADROOTS_POST_IMETA_MAX_COUNT, @@ -895,12 +908,14 @@ mod tests { for invalid in [ "", " https://media.example/path", + "\nhttps://media.example/path", "media.example/path", "ftp://media.example/path", "https://user@media.example/path", "https://user:password@media.example/path", "https://media.example", "https:///path", + "https://[invalid]/path", "not a URL://media.example/path", ] { assert!(!post_media_http_url_is_valid(invalid), "{invalid}"); diff --git a/crates/event/tests/package_boundary.rs b/crates/event/tests/package_boundary.rs @@ -143,7 +143,6 @@ fn crate_root_declares_every_approved_module() { fn crate_root_exposes_the_curated_native_event_vocabulary() { type CuratedRootTypes = ( radroots_event::Event, - radroots_event::EventDraft, radroots_event::GenericEventDraft, radroots_event::SignedEvent, radroots_event::VerifiedEvent, @@ -155,7 +154,6 @@ fn crate_root_exposes_the_curated_native_event_vocabulary() { let _: Option<CuratedRootTypes> = None; for root_export in [ "Event", - "EventDraft", "GenericEventDraft", "SignedEvent", "VerifiedEvent", diff --git a/crates/event/tests/source_boundary.rs b/crates/event/tests/source_boundary.rs @@ -21,7 +21,7 @@ const FORBIDDEN_EVENT_NAMES: &[ForbiddenEventName] = &[ }, ForbiddenEventName { pattern: "RadrootsFrozenEventDraft", - reason: "event construction must use EventDraft", + reason: "generic event construction must use GenericEventDraft", }, ForbiddenEventName { pattern: "RadrootsNostrEvent", @@ -156,7 +156,7 @@ fn typed_authoring_mapping_is_not_implemented_in_the_generic_event_draft() { ] { assert!( !draft.contains(retired), - "generic EventDraft retains codec-owned typed mapping `{retired}`" + "generic authored input retains codec-owned typed mapping `{retired}`" ); } } diff --git a/crates/event_codec/tests/wire.rs b/crates/event_codec/tests/wire.rs @@ -1,6 +1,6 @@ -use radroots_event::contract::ContractValidationError; -use radroots_event::draft::{DraftError, EventDraft}; -use radroots_event::envelope::kind::{KIND_GEOCHAT, KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE}; +use radroots_event::GenericEventDraft; +use radroots_event::draft::DraftError; +use radroots_event::envelope::kind::{KIND_GEOCHAT, KIND_PROFILE}; use radroots_event::wire::Nip01EventWireParts; use radroots_event_codec::decode::wire::{canonicalize_tags, empty_content}; @@ -46,7 +46,7 @@ fn draft_validation_accepts_wire_parts_without_signed_envelope() { tags: vec![vec!["t".to_string(), "a".to_string()]], }; - let draft = EventDraft::new( + let draft = GenericEventDraft::new( "radroots.social.geochat.v1", parts.kind, 99, @@ -65,65 +65,24 @@ fn draft_validation_accepts_wire_parts_without_signed_envelope() { } #[test] -fn draft_validation_rejects_contract_shape_errors() { - let missing_contract_tag = Nip01EventWireParts { - kind: KIND_KNOWLEDGE_CLAIM, - content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(), +fn generic_draft_rejects_typed_only_contracts() { + let typed_only = Nip01EventWireParts { + kind: KIND_PROFILE, + content: r#"{"name":"Alice"}"#.to_string(), tags: Vec::new(), }; - let error = EventDraft::new( - "radroots.knowledge.claim.v1", - missing_contract_tag.kind, + let error = GenericEventDraft::new( + "radroots.profile.metadata.v1", + typed_only.kind, 99, - missing_contract_tag.tags, - missing_contract_tag.content, + typed_only.tags, + typed_only.content, "a".repeat(64), ) - .expect_err("missing contract tag"); + .expect_err("typed-only contract"); assert!(matches!( error, - DraftError::ContractShape { - error: ContractValidationError::MissingTag { - name: "contract", - .. - }, - .. - } - )); - - let invalid_relay = Nip01EventWireParts { - kind: KIND_KNOWLEDGE_CLAIM, - content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(), - tags: vec![ - vec![ - "contract".to_string(), - "radroots.knowledge.claim.v1".to_string(), - ], - vec![ - "source".to_string(), - "b".repeat(64), - "a".repeat(64), - KIND_KNOWLEDGE_SOURCE.to_string(), - String::new(), - "http://relay.radroots.example".to_string(), - ], - ], - }; - let error = EventDraft::new( - "radroots.knowledge.claim.v1", - invalid_relay.kind, - 99, - invalid_relay.tags, - invalid_relay.content, - "a".repeat(64), - ) - .expect_err("invalid relay"); - assert!(matches!( - error, - DraftError::ContractShape { - error: ContractValidationError::TagValueMismatch { name: "source", .. }, - .. - } + DraftError::ContractNotDraftAuthorable { .. } )); } diff --git a/crates/mesh/src/model.rs b/crates/mesh/src/model.rs @@ -1,7 +1,7 @@ use crate::RadrootsMeshError; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use radroots_transport::RadrootsTransportError; +use radroots_transport::Error as TransportError; use radroots_transport::target::TargetScope; pub const RADROOTS_MESH_FRAME_VERSION: u16 = 1; @@ -67,7 +67,7 @@ pub enum RadrootsMeshScope { impl RadrootsMeshScope { pub fn custom(value: impl AsRef<str>) -> Result<Self, RadrootsMeshError> { let scope = TargetScope::parse(value.as_ref()).map_err(|err| match err { - RadrootsTransportError::EmptyTargetScope => RadrootsMeshError::EmptyCustomScope, + TransportError::EmptyTargetScope => RadrootsMeshError::EmptyCustomScope, _ => RadrootsMeshError::InvalidCustomScope, })?; Ok(Self::Custom(scope.as_str().to_string())) diff --git a/crates/nostr/src/draft_signing.rs b/crates/nostr/src/draft_signing.rs @@ -1,113 +0,0 @@ -//! Exact local signing helpers for frozen canonical Radroots drafts. -//! -//! Successful functions return a signed value and have no persistence or -//! publication commit point. - -#![forbid(unsafe_code)] - -use crate::error::Error; -use crate::events::build_event_unchecked; -use crate::types::{RadrootsNostrKeys, RadrootsNostrTimestamp}; -use nostr::JsonUtil; -use radroots_event::draft::{EventDraft, SignedEvent}; -use radroots_event::wire::Nip01EventWire; - -pub fn sign_frozen_draft( - keys: &RadrootsNostrKeys, - draft: &EventDraft, -) -> Result<SignedEvent, Error> { - draft.validate_for_signing()?; - let actual_pubkey = keys.public_key().to_hex(); - if actual_pubkey != draft.expected_pubkey().to_hex() { - return Err(Error::FrozenDraftPubkeyMismatch { - expected_pubkey: draft.expected_pubkey().to_hex().to_owned(), - actual_pubkey, - }); - } - - let event = build_event_unchecked( - draft.kind_u32(), - draft.content().to_owned(), - draft.tags_as_vec(), - )? - .custom_created_at(RadrootsNostrTimestamp::from_secs(draft.created_at_u64())) - .sign_with_keys(keys)?; - let actual_event_id = event.id.to_hex(); - let expected_event_id = draft.expected_event_id_hex(); - if actual_event_id != expected_event_id { - return Err(Error::FrozenDraftEventIdMismatch { - expected_event_id, - actual_event_id, - }); - } - - let raw_json = event.as_json(); - let wire = Nip01EventWire::parse_json(raw_json.as_str())?; - SignedEvent::from_wire_verified_id(wire, raw_json).map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::sign_frozen_draft; - use crate::error::Error; - use crate::test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB}; - use crate::types::RadrootsNostrKeys; - use nostr::JsonUtil; - use nostr::SecretKey; - use radroots_event::draft::EventDraft; - use radroots_event::envelope::kind::KIND_GEOCHAT; - - fn fixture_keys(secret_key_hex: &str) -> RadrootsNostrKeys { - let secret_key = SecretKey::from_hex(secret_key_hex).expect("secret key"); - RadrootsNostrKeys::new(secret_key) - } - - fn generic_draft(expected_pubkey: &str) -> EventDraft { - EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, - Vec::new(), - "hello", - expected_pubkey, - ) - .expect("draft") - } - - #[test] - fn sign_frozen_draft_uses_fixed_created_at_and_expected_id() { - let keys = fixture_keys(FIXTURE_ALICE.secret_key_hex); - let draft = generic_draft(FIXTURE_ALICE.public_key_hex); - let signed = sign_frozen_draft(&keys, &draft).expect("signed event"); - - assert_eq!(signed.id_str(), draft.expected_event_id_hex()); - assert_eq!(signed.pubkey().to_hex(), draft.expected_pubkey().to_hex()); - assert_eq!(signed.created_at(), draft.created_at_u64()); - assert_eq!(signed.kind(), draft.kind_u32()); - assert_eq!(signed.tags_as_vec(), draft.tags_as_vec()); - assert_eq!(signed.content(), draft.content()); - - let raw_event = - crate::types::RadrootsNostrEvent::from_json(signed.raw_json()).expect("raw json"); - assert_eq!(raw_event.id.to_hex(), signed.id_str()); - assert_eq!(raw_event.created_at.as_secs(), draft.created_at_u64()); - } - - #[test] - fn sign_frozen_draft_rejects_wrong_signer() { - let keys = fixture_keys(FIXTURE_BOB.secret_key_hex); - let draft = generic_draft(FIXTURE_ALICE.public_key_hex); - let error = sign_frozen_draft(&keys, &draft).expect_err("wrong signer"); - - assert!(matches!(error, Error::FrozenDraftPubkeyMismatch { .. })); - } - - #[test] - fn frozen_draft_deserialization_rejects_event_id_mismatch() { - let draft = generic_draft(FIXTURE_ALICE.public_key_hex); - let mut raw = serde_json::to_value(&draft).expect("draft json"); - raw["expected_event_id"] = serde_json::Value::String("f".repeat(64)); - serde_json::from_value::<EventDraft>(raw) - .expect_err("tampered draft must fail before signing"); - } -} diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs @@ -100,22 +100,6 @@ pub enum Error { #[error("Signed event error: {0}")] SignedEvent(#[from] radroots_event::draft::SignedEventError), - #[cfg(feature = "events")] - #[error( - "Frozen draft signer public key mismatch: expected {expected_pubkey}, got {actual_pubkey}" - )] - FrozenDraftPubkeyMismatch { - expected_pubkey: String, - actual_pubkey: String, - }, - - #[cfg(feature = "events")] - #[error("Frozen draft event ID mismatch: expected {expected_event_id}, got {actual_event_id}")] - FrozenDraftEventIdMismatch { - expected_event_id: String, - actual_event_id: String, - }, - #[error("Key error: {0}")] KeyError(#[cfg_attr(feature = "std", source)] nostr::key::Error), diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -41,8 +41,6 @@ pub mod signing; #[cfg(feature = "events")] mod event_adapters; -#[cfg(feature = "signing")] -mod draft_signing; #[cfg(feature = "events")] mod event_convert; #[cfg(feature = "events")] diff --git a/crates/nostr/src/signing.rs b/crates/nostr/src/signing.rs @@ -21,8 +21,6 @@ use radroots_signing::{ use crate::{Error as NostrError, key::SecretKey}; -pub use crate::draft_signing::sign_frozen_draft; - type Clock = fn() -> Result<u64, SigningError>; /// A local Nostr key-backed signer adapter. @@ -124,10 +122,8 @@ fn system_time_unix_ms() -> Result<u64, SigningError> { fn normalize_nostr_error(source: NostrError) -> SigningError { let kind = match &source { - NostrError::FrozenDraftPubkeyMismatch { .. } - | NostrError::ExternalSigningAuthorMismatch { .. } => Kind::AuthorizationDenied, - NostrError::FrozenDraftEventIdMismatch { .. } - | NostrError::ExternalSigningEventIdMismatch { .. } + NostrError::ExternalSigningAuthorMismatch { .. } => Kind::AuthorizationDenied, + NostrError::ExternalSigningEventIdMismatch { .. } | NostrError::ExternalSigningEventInvalid(_) | NostrError::ExternalSigningPlanMismatch { .. } => Kind::SignerOutputInvalid, _ => Kind::InternalError, diff --git a/crates/nostr/tests/coverage.rs b/crates/nostr/tests/coverage.rs @@ -412,6 +412,36 @@ fn event_and_job_adapters_cover_native_value_boundaries() { .sign_with_keys(&keys) .unwrap(); assert!(to_profile_event_metadata(&unrelated_tag_profile).is_some()); + let typed_profile = + nostr::EventBuilder::new(RadrootsNostrKind::Metadata, profile_event.content.clone()) + .tag(RadrootsNostrTag::custom( + RadrootsNostrTagKind::Custom(Cow::Borrowed("t")), + vec!["radroots:type:farm".to_string()], + )) + .sign_with_keys(&keys) + .unwrap(); + assert!( + to_profile_event_metadata(&typed_profile) + .expect("typed profile") + .data + .profile_type + .is_some() + ); + let unknown_profile_type = + nostr::EventBuilder::new(RadrootsNostrKind::Metadata, profile_event.content.clone()) + .tag(RadrootsNostrTag::custom( + RadrootsNostrTagKind::Custom(Cow::Borrowed("t")), + vec!["radroots:type:unknown".to_string()], + )) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + to_profile_event_metadata(&unknown_profile_type) + .expect("profile with unknown type") + .data + .profile_type, + None + ); let invalid_profile = nostr::EventBuilder::new(RadrootsNostrKind::Metadata, "not-json") .sign_with_keys(&keys) .unwrap(); diff --git a/crates/storage/src/atomic.rs b/crates/storage/src/atomic.rs @@ -5,14 +5,11 @@ //! observed after a successful commit cannot claim rollback; replaying the same //! commit identity and digest returns the original receipt. -use radroots_event::{EventId, SignedEvent}; use radroots_transport::BoxFuture; use crate::{ Error, event::{AdmissionReceipt, EventAdmission}, - journal::{JournalRevision, OperationInstanceId, OperationRecord, PrepareOperation}, - outbox::{DeliveryAttemptEvidence, EnqueueOutboxItem, OutboxRecord}, projection::{ProjectionCheckpoint, ProjectionStatus}, }; @@ -50,96 +47,9 @@ impl AtomicCommitDigest { #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AtomicWorkflowKind { - Prepared, - Signed, - Enqueued, - Delivered, Ingested, } -/// Journal transition inputs for a newly signed operation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CommitSigned { - instance_id: OperationInstanceId, - expected_revision: JournalRevision, - event: SignedEvent, -} - -impl CommitSigned { - pub fn new( - instance_id: OperationInstanceId, - expected_revision: JournalRevision, - event: SignedEvent, - ) -> Self { - Self { - instance_id, - expected_revision, - event, - } - } - pub const fn instance_id(&self) -> OperationInstanceId { - self.instance_id - } - pub const fn expected_revision(&self) -> JournalRevision { - self.expected_revision - } - pub const fn event(&self) -> &SignedEvent { - &self.event - } -} - -/// Atomic canonical admission, journal commit, and delivery-plan enqueue. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CommitEnqueued { - instance_id: OperationInstanceId, - expected_revision: JournalRevision, - admission: EventAdmission, - outbox: EnqueueOutboxItem, - committed_at_unix_ms: u64, -} - -impl CommitEnqueued { - pub fn new( - instance_id: OperationInstanceId, - expected_revision: JournalRevision, - admission: EventAdmission, - outbox: EnqueueOutboxItem, - committed_at_unix_ms: u64, - ) -> Result<Self, Error> { - if [ - committed_at_unix_ms == 0, - admission.event_id() != outbox.request().payload().event().id(), - outbox.operation_instance_id() != instance_id, - ] - .contains(&true) - { - return Err(Error::AtomicWorkflowMismatch); - } - Ok(Self { - instance_id, - expected_revision, - admission, - outbox, - committed_at_unix_ms, - }) - } - pub const fn instance_id(&self) -> OperationInstanceId { - self.instance_id - } - pub const fn expected_revision(&self) -> JournalRevision { - self.expected_revision - } - pub const fn admission(&self) -> &EventAdmission { - &self.admission - } - pub const fn outbox(&self) -> &EnqueueOutboxItem { - &self.outbox - } - pub const fn committed_at_unix_ms(&self) -> u64 { - self.committed_at_unix_ms - } -} - /// Atomic inbound admission with an optional projection checkpoint advance. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommitIngested { @@ -164,20 +74,12 @@ impl CommitIngested { #[derive(Clone, Debug, Eq, PartialEq)] pub enum AtomicWorkflow { - Prepared(PrepareOperation), - Signed(Box<CommitSigned>), - Enqueued(Box<CommitEnqueued>), - Delivered(Box<DeliveryAttemptEvidence>), Ingested(Box<CommitIngested>), } impl AtomicWorkflow { pub const fn kind(&self) -> AtomicWorkflowKind { match self { - Self::Prepared(_) => AtomicWorkflowKind::Prepared, - Self::Signed(_) => AtomicWorkflowKind::Signed, - Self::Enqueued(_) => AtomicWorkflowKind::Enqueued, - Self::Delivered(_) => AtomicWorkflowKind::Delivered, Self::Ingested(_) => AtomicWorkflowKind::Ingested, } } @@ -233,21 +135,6 @@ pub enum AtomicCommitDisposition { #[derive(Clone, Debug, Eq, PartialEq)] pub enum AtomicCommitOutcome { - Prepared { - journal: OperationRecord, - }, - Signed { - journal: OperationRecord, - event_id: EventId, - }, - Enqueued { - journal: OperationRecord, - admission: AdmissionReceipt, - outbox: Box<OutboxRecord>, - }, - Delivered { - outbox: Box<OutboxRecord>, - }, Ingested { admission: AdmissionReceipt, projection: Option<Box<ProjectionStatus>>, @@ -257,10 +144,6 @@ pub enum AtomicCommitOutcome { impl AtomicCommitOutcome { pub const fn kind(&self) -> AtomicWorkflowKind { match self { - Self::Prepared { .. } => AtomicWorkflowKind::Prepared, - Self::Signed { .. } => AtomicWorkflowKind::Signed, - Self::Enqueued { .. } => AtomicWorkflowKind::Enqueued, - Self::Delivered { .. } => AtomicWorkflowKind::Delivered, Self::Ingested { .. } => AtomicWorkflowKind::Ingested, } } diff --git a/crates/storage/src/authored.rs b/crates/storage/src/authored.rs @@ -526,9 +526,8 @@ impl ExactSignedArtifact { event, raw_json_sha256, }; - if Sha256::digest(artifact.event.raw_json().as_bytes()).as_slice() - != artifact.raw_json_sha256 - { + let computed_sha256: [u8; 32] = Sha256::digest(artifact.event.raw_json().as_bytes()).into(); + if computed_sha256 != artifact.raw_json_sha256 { return Err(Error::InvalidAuthoredArtifact); } Ok(artifact) @@ -886,7 +885,11 @@ impl AuthoredArtifact { *self = previous; return Err(error); } - self.validate() + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) } pub fn record_signing_failure( @@ -982,7 +985,11 @@ impl AuthoredArtifact { *self = previous; return Err(error); } - self.validate() + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) } pub fn record_admission( @@ -1416,3 +1423,463 @@ fn valid_code(value: &str) -> bool { byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.') }) } + +#[cfg(test)] +mod invariant_tests { + use super::*; + use radroots_event::{GenericEventDraft, wire::v1::Nip01EventWire}; + + const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + + fn operation_id() -> OperationInstanceId { + OperationInstanceId::new([1; 16]).unwrap() + } + + fn artifact_id(value: u8) -> AuthoredArtifactId { + AuthoredArtifactId::new([value; 16]).unwrap() + } + + fn plan() -> AuthoredEventPlan { + AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + 1_800_000_100, + Vec::new(), + "authored invariant", + AUTHOR, + ) + .unwrap(), + ) + .unwrap() + } + + fn signed(plan: &AuthoredEventPlan) -> SignedEvent { + let wire = Nip01EventWire { + id: plan.expected_event_id().to_hex(), + pubkey: plan.author().to_hex(), + created_at: plan.created_at(), + kind: plan.body().kind(), + tags: plan.body().tags().to_vec(), + content: plan.body().content().to_owned(), + sig: "dd".repeat(64), + extra: Default::default(), + }; + let raw = serde_json::to_string(&wire).unwrap(); + SignedEvent::from_wire_verified_id(wire, raw).unwrap() + } + + fn planned(value: u8) -> AuthoredArtifact { + AuthoredArtifact::planned(artifact_id(value), operation_id(), 0, &plan(), 10).unwrap() + } + + fn imported(value: u8) -> AuthoredArtifact { + let plan = plan(); + AuthoredArtifact::imported_signed(artifact_id(value), operation_id(), 0, signed(&plan), 10) + .unwrap() + } + + fn valid_claim(revision: NonZeroU64, at: u64) -> WorkClaim { + WorkClaim::new([7; 16], "worker", NonZeroU64::MIN, at, at + 10, revision).unwrap() + } + + fn failure(phase: WorkPhase, class: FailureClass) -> WorkFailure { + let retry_at = (class == FailureClass::Retryable).then_some(30); + WorkFailure::new("failure", phase, class, retry_at, None).unwrap() + } + + fn retry(phase: WorkPhase) -> RetrySchedule { + RetrySchedule::new(NonZeroU32::MIN, 30, failure(phase, FailureClass::Retryable)).unwrap() + } + + #[test] + fn validator_rejects_corruption_at_each_nested_invariant() { + let mut value = imported(1); + value.signing_claim = Some(valid_claim(value.revision, 10)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = imported(2); + value.signing_retry = Some(retry(WorkPhase::Signing)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = planned(3); + value.plan = Some(DurableAuthoredPlan { + wire_json: vec![0xff], + }); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = imported(4); + value.admission_state = AdmissionState::Retryable; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut claimed = planned(5); + claimed + .set_signing_claim(valid_claim(claimed.revision, 11), 11) + .unwrap(); + let mut value = claimed.clone(); + value.signing_claim.as_mut().unwrap().token = [0; 16]; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = claimed.clone(); + value.signing_state = SigningState::Cancelled; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = claimed.clone(); + value.signing_claim.as_mut().unwrap().row_revision = value.revision; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = claimed; + value.signing_claim.as_mut().unwrap().acquired_at_unix_ms += 1; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let authored_plan = plan(); + let mut admission = planned(6); + admission.record_signed(signed(&authored_plan), 11).unwrap(); + admission + .set_admission_claim(valid_claim(admission.revision, 12), 12) + .unwrap(); + let mut value = admission.clone(); + value.admission_claim.as_mut().unwrap().token = [0; 16]; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = admission.clone(); + value.signed = None; + value.signing_state = SigningState::Planned; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = admission.clone(); + value.admission_state = AdmissionState::Inserted; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = admission.clone(); + value.admission_claim.as_mut().unwrap().row_revision = value.revision; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = admission; + value.admission_claim.as_mut().unwrap().acquired_at_unix_ms += 1; + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = planned(7); + value.signing_state = SigningState::Retryable; + value.signing_retry = Some(retry(WorkPhase::Admission)); + value.last_failure = value + .signing_retry + .as_ref() + .map(|schedule| schedule.failure.clone()); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = imported(8); + value.admission_state = AdmissionState::Retryable; + value.admission_retry = Some(retry(WorkPhase::Signing)); + value.last_failure = value + .admission_retry + .as_ref() + .map(|schedule| schedule.failure.clone()); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = planned(9); + value.last_failure = Some(WorkFailure { + code: "INVALID".to_owned(), + phase: WorkPhase::Signing, + class: FailureClass::Terminal, + retry_after_unix_ms: None, + diagnostic: None, + }); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + + let mut value = planned(10); + value.signing_state = SigningState::Indeterminate; + value.last_failure = Some(failure(WorkPhase::Signing, FailureClass::Terminal)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = planned(11); + value.signing_state = SigningState::FailedTerminal; + value.last_failure = Some(failure(WorkPhase::Signing, FailureClass::Indeterminate)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = imported(12); + value.admission_state = AdmissionState::Rejected; + value.last_failure = Some(failure(WorkPhase::Signing, FailureClass::Terminal)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + let mut value = planned(13); + value.last_failure = Some(failure(WorkPhase::Signing, FailureClass::Terminal)); + assert_eq!(value.validate(), Err(Error::InvalidAuthoredArtifact)); + } + + #[test] + fn every_mutating_transition_rolls_back_on_revision_overflow() { + let authored_plan = plan(); + + let mut value = planned(20); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.record_signed(signed(&authored_plan), 11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + + let mut value = planned(21); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.set_signing_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + + let mut value = planned(22); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal), + None, + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + + let mut value = planned(23); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.cancel_signing(11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + + let mut value = imported(24); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.set_admission_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + + let mut value = imported(25); + value.revision = NonZeroU64::MAX; + let before = value.clone(); + assert_eq!( + value.record_admission(AdmissionState::Inserted, None, None, 11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(value, before); + } + + #[test] + fn every_mutating_transition_rolls_back_when_post_state_is_invalid() { + let authored_plan = plan(); + let mut values = (30_u8..34).map(planned).collect::<Vec<_>>(); + for value in &mut values { + value.plan = None; + } + + let before = values[0].clone(); + assert_eq!( + values[0].record_signed(signed(&authored_plan), 11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(values[0], before); + + let before = values[1].clone(); + let revision = values[1].revision; + assert_eq!( + values[1].set_signing_claim(valid_claim(revision, 11), 11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(values[1], before); + + let before = values[2].clone(); + assert_eq!( + values[2].record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal), + None, + 11, + ), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(values[2], before); + + let before = values[3].clone(); + assert_eq!( + values[3].cancel_signing(11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(values[3], before); + + let mut value = imported(34); + value.origin = ArtifactOrigin::Planned; + let before = value.clone(); + assert_eq!( + value.set_admission_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(value, before); + + let mut value = imported(35); + value.origin = ArtifactOrigin::Planned; + let before = value.clone(); + assert_eq!( + value.record_admission(AdmissionState::Inserted, None, None, 11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(value, before); + } + + fn empty_settlement() -> OperationSettlement { + OperationSettlement { + artifacts: 1, + signed: 1, + admitted: 1, + pending: 0, + retryable: 0, + indeterminate: 0, + failed_terminal: 0, + cancelled: 0, + delivery_plans: 0, + delivery_satisfied: 0, + delivery_pending: 0, + delivery_retryable: 0, + delivery_exhausted: 0, + delivery_failed_terminal: 0, + delivery_cancelled: 0, + } + } + + #[test] + fn settlement_predicates_evaluate_every_independent_dimension() { + assert!(empty_settlement().is_settled()); + for mutate in [ + |value: &mut OperationSettlement| value.pending = 1, + |value: &mut OperationSettlement| value.retryable = 1, + |value: &mut OperationSettlement| value.indeterminate = 1, + |value: &mut OperationSettlement| value.delivery_pending = 1, + |value: &mut OperationSettlement| value.delivery_retryable = 1, + ] { + let mut value = empty_settlement(); + mutate(&mut value); + assert!(!value.is_settled()); + } + + assert!(!empty_settlement().has_failures()); + for mutate in [ + |value: &mut OperationSettlement| value.indeterminate = 1, + |value: &mut OperationSettlement| value.failed_terminal = 1, + |value: &mut OperationSettlement| value.cancelled = 1, + |value: &mut OperationSettlement| value.delivery_exhausted = 1, + |value: &mut OperationSettlement| value.delivery_failed_terminal = 1, + |value: &mut OperationSettlement| value.delivery_cancelled = 1, + ] { + let mut value = empty_settlement(); + mutate(&mut value); + assert!(value.has_failures()); + } + + assert!(empty_settlement().is_successful()); + for mutate in [ + |value: &mut OperationSettlement| value.pending = 1, + |value: &mut OperationSettlement| value.failed_terminal = 1, + |value: &mut OperationSettlement| value.signed = 0, + |value: &mut OperationSettlement| value.admitted = 0, + |value: &mut OperationSettlement| value.delivery_plans = 1, + ] { + let mut value = empty_settlement(); + mutate(&mut value); + assert!(!value.is_successful()); + } + } + + #[test] + fn settlement_rejects_each_artifact_identity_and_integrity_mismatch() { + let artifact = planned(40); + let operation = + AuthoredOperation::new(operation_id(), vec![artifact.artifact_id()], 10).unwrap(); + assert_eq!( + OperationSettlement::evaluate(&operation, &[]), + Err(Error::InvalidAuthoredOperation) + ); + + let mut wrong_operation = artifact.clone(); + wrong_operation.operation_id = OperationInstanceId::new([2; 16]).unwrap(); + assert_eq!( + OperationSettlement::evaluate(&operation, &[wrong_operation]), + Err(Error::InvalidAuthoredOperation) + ); + let mut wrong_id = artifact.clone(); + wrong_id.artifact_id = artifact_id(41); + assert_eq!( + OperationSettlement::evaluate(&operation, &[wrong_id]), + Err(Error::InvalidAuthoredOperation) + ); + let mut wrong_ordinal = artifact.clone(); + wrong_ordinal.ordinal = 1; + assert_eq!( + OperationSettlement::evaluate(&operation, &[wrong_ordinal]), + Err(Error::InvalidAuthoredOperation) + ); + let mut invalid = artifact; + invalid.plan = None; + assert_eq!( + OperationSettlement::evaluate(&operation, &[invalid]), + Err(Error::InvalidAuthoredOperation) + ); + } + + #[test] + fn transition_preconditions_distinguish_origin_state_and_signed_presence() { + let authored_plan = plan(); + + let mut value = planned(50); + value.signing_state = SigningState::Cancelled; + assert_eq!( + value.record_signed(signed(&authored_plan), 11), + Err(Error::InvalidAuthoredTransition) + ); + + let mut value = imported(51); + assert_eq!( + value.set_signing_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); + let mut value = planned(52); + value.signing_state = SigningState::Cancelled; + assert_eq!( + value.set_signing_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); + + let mut value = imported(53); + assert_eq!( + value.record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal), + None, + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + let mut value = planned(54); + value.signing_state = SigningState::Cancelled; + assert_eq!( + value.record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal), + None, + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + + let mut value = imported(55); + value.admission_state = AdmissionState::Inserted; + assert_eq!( + value.set_admission_claim(valid_claim(value.revision, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); + + let mut missing_signed = imported(56); + missing_signed.signed = None; + assert_eq!( + missing_signed.record_admission(AdmissionState::Inserted, None, None, 11), + Err(Error::InvalidAuthoredTransition) + ); + let mut terminal_admission = imported(57); + terminal_admission.admission_state = AdmissionState::Inserted; + assert_eq!( + terminal_admission.record_admission(AdmissionState::Duplicate, None, None, 11), + Err(Error::InvalidAuthoredTransition) + ); + } +} diff --git a/crates/storage/src/authored_delivery.rs b/crates/storage/src/authored_delivery.rs @@ -568,7 +568,11 @@ impl AuthoredDeliveryPlan { *self = previous; return Err(error); } - self.validate() + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) } fn apply_attempt( diff --git a/crates/storage/src/memory.rs b/crates/storage/src/memory.rs @@ -1217,55 +1217,6 @@ impl AtomicStorage for MemoryStorage { } let mut candidate = state.clone(); let outcome = match request.workflow().clone() { - AtomicWorkflow::Prepared(operation) => AtomicCommitOutcome::Prepared { - journal: Self::prepare_locked(&mut candidate, operation)? - .record() - .clone(), - }, - AtomicWorkflow::Signed(signed) => { - let event_id = *signed.event().id(); - let journal = Self::transition_locked( - &mut candidate, - JournalTransition::signed( - signed.instance_id(), - signed.expected_revision(), - event_id, - ), - )?; - AtomicCommitOutcome::Signed { journal, event_id } - } - AtomicWorkflow::Enqueued(enqueued) => { - let admission = - self.admit_locked(&mut candidate, enqueued.admission().clone())?; - let outbox = Self::enqueue_locked(&mut candidate, enqueued.outbox().clone())? - .record() - .clone(); - let journal = Self::transition_locked( - &mut candidate, - JournalTransition::committed( - enqueued.instance_id(), - enqueued.expected_revision(), - *enqueued.admission().event_id(), - enqueued.committed_at_unix_ms(), - ), - )?; - AtomicCommitOutcome::Enqueued { - journal, - admission, - outbox: Box::new(outbox), - } - } - AtomicWorkflow::Delivered(evidence) => { - let record = candidate - .outbox - .iter_mut() - .find(|record| record.item_id() == evidence.item_id()) - .ok_or(Error::OutboxItemNotFound)?; - record.record_attempt(*evidence)?; - AtomicCommitOutcome::Delivered { - outbox: Box::new(record.clone()), - } - } AtomicWorkflow::Ingested(ingested) => { let admission = self.admit_locked(&mut candidate, ingested.admission().clone())?; diff --git a/crates/storage/tests/atomic.rs b/crates/storage/tests/atomic.rs @@ -1,237 +1,164 @@ use futures_executor::block_on; -use radroots_protocol::runtime::v1::OperationId; +use radroots_event::{SignedEvent, wire::Nip01EventWire}; use radroots_storage::{ Error, atomic::{ AtomicCommit, AtomicCommitDigest, AtomicCommitDisposition, AtomicCommitId, AtomicCommitOutcome, AtomicCommitReceipt, AtomicStorage, AtomicWorkflow, + AtomicWorkflowKind, CommitIngested, }, - journal::{IdempotencyDigest, IdempotencyKey, OperationInstanceId, PrepareOperation}, -}; -use radroots_transport::BoxFuture; -use std::{ - collections::BTreeMap, - sync::{ - Mutex, - atomic::{AtomicBool, Ordering}, + event::{ + AdmissionDisposition, AdmissionReceipt, AdmissionStage, EventAdmission, EventPosition, + EventSequence, SourceGeneration, }, + memory::MemoryStorage, +}; +use radroots_transport::{ + Target, TransportId, + source::{EventProvenance, ObservedEvent}, }; -struct ReferenceAtomic { - receipts: Mutex<BTreeMap<AtomicCommitId, AtomicCommitReceipt>>, - fail_before_commit: AtomicBool, +fn signed_event() -> SignedEvent { + let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; + SignedEvent::from_wire_verified_id(Nip01EventWire::parse_json(raw).expect("wire event"), raw) + .expect("signed event") } -impl ReferenceAtomic { - fn new() -> Self { - Self { - receipts: Mutex::new(BTreeMap::new()), - fail_before_commit: AtomicBool::new(false), - } - } +fn admission(event: SignedEvent) -> EventAdmission { + let target = Target::nostr_relay("wss://relay.example").expect("target"); + let provenance = EventProvenance::new(TransportId::NOSTR, target.fingerprint().clone(), 100) + .expect("provenance"); + EventAdmission::raw(ObservedEvent::new(event, provenance)) } -impl AtomicStorage for ReferenceAtomic { - fn commit(&self, request: AtomicCommit) -> BoxFuture<'_, Result<AtomicCommitReceipt, Error>> { - Box::pin(async move { - let mut receipts = self.receipts.lock().expect("atomic test lock"); - if let Some(existing) = receipts.get(&request.commit_id()) { - if existing.digest() != request.digest() - || existing.outcome().kind() != request.workflow().kind() - { - return Err(Error::AtomicCommitConflict); - } - return AtomicCommitReceipt::new( - &request, - AtomicCommitDisposition::Replay, - existing.committed_at_unix_ms(), - existing.outcome().clone(), - ); - } - if self.fail_before_commit.swap(false, Ordering::SeqCst) { - return Err(Error::AtomicCommitFailed); - } - let outcome = match request.workflow().clone() { - AtomicWorkflow::Prepared(operation) => AtomicCommitOutcome::Prepared { - journal: operation.into_record()?, - }, - _ => return Err(Error::AtomicCommitFailed), - }; - let receipt = AtomicCommitReceipt::new( - &request, - AtomicCommitDisposition::Committed, - request.requested_at_unix_ms(), - outcome, - )?; - receipts.insert(request.commit_id(), receipt.clone()); - Ok(receipt) - }) - } - - fn receipt( - &self, - commit_id: AtomicCommitId, - ) -> BoxFuture<'_, Result<Option<AtomicCommitReceipt>, Error>> { - Box::pin(async move { - Ok(self - .receipts - .lock() - .expect("atomic test lock") - .get(&commit_id) - .cloned()) - }) - } -} - -fn request(commit_byte: u8, digest_byte: u8) -> AtomicCommit { - let prepare = PrepareOperation::new( - OperationInstanceId::new([9; 16]).expect("operation instance"), - OperationId::TradePrivateArtifactSeal, - IdempotencyKey::parse("atomic-test-key").expect("idempotency key"), - IdempotencyDigest::new([3; 32]), - 100, - ) - .expect("prepare operation"); +fn commit_request(id: u8, digest: u8) -> AtomicCommit { AtomicCommit::new( - AtomicCommitId::new([commit_byte; 16]).expect("commit id"), - AtomicCommitDigest::new([digest_byte; 32]), + AtomicCommitId::new([id; 16]).expect("commit ID"), + AtomicCommitDigest::new([digest; 32]), 100, - AtomicWorkflow::Prepared(prepare), + AtomicWorkflow::Ingested(Box::new(CommitIngested::new( + admission(signed_event()), + None, + ))), ) - .expect("atomic commit") + .expect("commit") +} + +fn outcome() -> AtomicCommitOutcome { + let event = signed_event(); + AtomicCommitOutcome::Ingested { + admission: AdmissionReceipt::new( + *event.id(), + EventPosition::new( + SourceGeneration::new([1; 32]).expect("generation"), + EventSequence::new(1).expect("sequence"), + ), + AdmissionStage::Raw, + AdmissionDisposition::Inserted, + ), + projection: None, + } } #[test] -fn failure_before_commit_leaves_no_partial_receipt() { - let store = ReferenceAtomic::new(); - let request = request(1, 2); - store.fail_before_commit.store(true, Ordering::SeqCst); - assert_eq!( - block_on(store.commit(request.clone())), - Err(Error::AtomicCommitFailed) - ); +fn atomic_identity_workflow_and_receipt_models_are_exact() { assert_eq!( - block_on(store.receipt(request.commit_id())).expect("receipt query"), - None + AtomicCommitId::new([0; 16]), + Err(Error::InvalidAtomicCommitId) ); - - let committed = block_on(store.commit(request.clone())).expect("commit"); - assert_eq!(committed.disposition(), AtomicCommitDisposition::Committed); - assert_eq!(committed.commit_id(), request.commit_id()); - assert_eq!(committed.digest(), request.digest()); - assert_eq!(committed.committed_at_unix_ms(), 100); + let request = commit_request(1, 2); assert_eq!(request.commit_id().as_bytes(), &[1; 16]); assert_eq!(request.digest().as_bytes(), &[2; 32]); assert_eq!(request.requested_at_unix_ms(), 100); + assert_eq!(request.workflow().kind(), AtomicWorkflowKind::Ingested); + let AtomicWorkflow::Ingested(ingested) = request.workflow(); + assert_eq!(ingested.admission().event().id(), signed_event().id()); + assert_eq!(ingested.projection(), None); + assert_eq!( - request.workflow().kind(), - radroots_storage::atomic::AtomicWorkflowKind::Prepared + AtomicCommit::new( + request.commit_id(), + request.digest(), + 0, + request.workflow().clone(), + ), + Err(Error::InvalidAtomicCommitTimestamp) ); - assert!( - block_on(store.receipt(request.commit_id())) - .expect("receipt query") - .is_some() + + let receipt = + AtomicCommitReceipt::new(&request, AtomicCommitDisposition::Committed, 100, outcome()) + .expect("receipt"); + assert_eq!(receipt.commit_id(), request.commit_id()); + assert_eq!(receipt.digest(), request.digest()); + assert_eq!(receipt.disposition(), AtomicCommitDisposition::Committed); + assert_eq!(receipt.committed_at_unix_ms(), 100); + assert_eq!(receipt.outcome().kind(), AtomicWorkflowKind::Ingested); + assert_eq!( + AtomicCommitReceipt::new(&request, AtomicCommitDisposition::Committed, 99, outcome(),), + Err(Error::AtomicWorkflowMismatch) ); } #[test] -fn exact_commit_replays_and_digest_reuse_conflicts() { - let store = ReferenceAtomic::new(); - let original_request = request(1, 2); - let original = block_on(store.commit(original_request.clone())).expect("commit"); - let replay = block_on(store.commit(original_request)).expect("replay"); - assert_eq!(replay.disposition(), AtomicCommitDisposition::Replay); - assert_eq!(replay.outcome(), original.outcome()); - assert_eq!( - block_on(store.commit(request(1, 4))), - Err(Error::AtomicCommitConflict) - ); +fn durable_receipt_reconstruction_rejects_timestamp_incoherence() { + let request = commit_request(1, 2); + for (requested_at, committed_at) in [(0, 100), (101, 100)] { + assert_eq!( + AtomicCommitReceipt::from_durable_parts( + request.commit_id(), + request.digest(), + AtomicCommitDisposition::Replay, + requested_at, + committed_at, + AtomicWorkflowKind::Ingested, + outcome(), + ), + Err(Error::AtomicWorkflowMismatch) + ); + } + + let receipt = AtomicCommitReceipt::from_durable_parts( + request.commit_id(), + request.digest(), + AtomicCommitDisposition::Replay, + 100, + 101, + AtomicWorkflowKind::Ingested, + outcome(), + ) + .expect("durable receipt"); + assert_eq!(receipt.disposition(), AtomicCommitDisposition::Replay); + assert_eq!(receipt.committed_at_unix_ms(), 101); } #[test] -fn atomic_contract_is_dyn_compatible_and_rejects_invalid_identity_and_time() { +fn memory_atomic_boundary_replays_exactly_and_conflicts_on_digest_reuse() { fn accepts_dyn(_: &dyn AtomicStorage) {} - let store = ReferenceAtomic::new(); + + let store = MemoryStorage::default(); accepts_dyn(&store); + let request = commit_request(1, 2); assert_eq!( - AtomicCommitId::new([0; 16]), - Err(Error::InvalidAtomicCommitId) + block_on(store.commit(request.clone())) + .expect("commit") + .disposition(), + AtomicCommitDisposition::Committed ); - let valid = request(1, 2); assert_eq!( - AtomicCommit::new( - valid.commit_id(), - valid.digest(), - 0, - valid.workflow().clone() - ), - Err(Error::InvalidAtomicCommitTimestamp) - ); - - let outcome = match valid.workflow().clone() { - AtomicWorkflow::Prepared(operation) => AtomicCommitOutcome::Prepared { - journal: operation.into_record().expect("journal record"), - }, - _ => unreachable!(), - }; - assert_eq!( - AtomicCommitReceipt::new( - &valid, - AtomicCommitDisposition::Committed, - 99, - outcome.clone(), - ), - Err(Error::AtomicWorkflowMismatch) + block_on(store.commit(request.clone())) + .expect("replay") + .disposition(), + AtomicCommitDisposition::Replay ); assert_eq!( - AtomicCommitReceipt::from_durable_parts( - valid.commit_id(), - valid.digest(), - AtomicCommitDisposition::Committed, - 0, - 100, - radroots_storage::atomic::AtomicWorkflowKind::Prepared, - outcome.clone(), - ), - Err(Error::AtomicWorkflowMismatch) - ); - assert_eq!( - AtomicCommitReceipt::from_durable_parts( - valid.commit_id(), - valid.digest(), - AtomicCommitDisposition::Committed, - 100, - 99, - radroots_storage::atomic::AtomicWorkflowKind::Prepared, - outcome.clone(), - ), - Err(Error::AtomicWorkflowMismatch) + block_on(store.commit(commit_request(1, 3))), + Err(Error::AtomicCommitConflict) ); assert_eq!( - AtomicCommitReceipt::from_durable_parts( - valid.commit_id(), - valid.digest(), - AtomicCommitDisposition::Committed, - 100, - 100, - radroots_storage::atomic::AtomicWorkflowKind::Signed, - outcome.clone(), - ), - Err(Error::AtomicWorkflowMismatch) + block_on(store.receipt(request.commit_id())) + .expect("receipt") + .expect("durable receipt") + .digest(), + request.digest() ); - let reconstructed = AtomicCommitReceipt::from_durable_parts( - valid.commit_id(), - valid.digest(), - AtomicCommitDisposition::Replay, - 100, - 101, - radroots_storage::atomic::AtomicWorkflowKind::Prepared, - outcome.clone(), - ) - .expect("durable receipt"); - assert_eq!(reconstructed.commit_id(), valid.commit_id()); - assert_eq!(reconstructed.digest(), valid.digest()); - assert_eq!(reconstructed.disposition(), AtomicCommitDisposition::Replay); - assert_eq!(reconstructed.committed_at_unix_ms(), 101); - assert_eq!(reconstructed.outcome(), &outcome); } diff --git a/crates/storage/tests/authored.rs b/crates/storage/tests/authored.rs @@ -4,12 +4,20 @@ use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_storage::{ Error, authored::{ - AdmissionState, ArtifactOrigin, AuthoredArtifact, AuthoredArtifactId, AuthoredOperation, - FailureClass, OperationSettlement, RetrySchedule, SigningState, WorkClaim, WorkFailure, - WorkPhase, + AUTHORED_OPERATION_ARTIFACTS_MAX, AdmissionState, ArtifactOrigin, AuthoredArtifact, + AuthoredArtifactId, AuthoredOperation, FailureClass, OperationSettlement, RetrySchedule, + SigningState, WORK_CLAIM_OWNER_MAX_BYTES, WORK_FAILURE_CODE_MAX_BYTES, + WORK_FAILURE_DIAGNOSTIC_MAX_BYTES, WorkClaim, WorkFailure, WorkPhase, }, + authored_delivery::{AuthoredDeliveryPlan, AuthoredDeliveryPlanId}, journal::OperationInstanceId, }; +use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, SinkFailure, Target, TargetSet, + outcome::{DeliveryOutcome, Retryability}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::{DeliveryPayload, DeliveryTargetReceipt}, +}; const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const CREATED_AT: u64 = 1_800_000_100; @@ -224,3 +232,949 @@ fn durable_models_round_trip_and_reject_forged_state() { forged_operation["artifact_ids"][1] = forged_operation["artifact_ids"][0].clone(); assert!(serde_json::from_value::<AuthoredOperation>(forged_operation).is_err()); } + +#[test] +fn claim_validation_and_fence_dimensions_are_independent() { + let revision = NonZeroU64::new(7).unwrap(); + let generation = NonZeroU64::new(3).unwrap(); + let claim = WorkClaim::new([9; 16], "worker", generation, 10, 20, revision).unwrap(); + assert_eq!(claim.token(), &[9; 16]); + assert_eq!(claim.owner(), "worker"); + assert_eq!(claim.generation(), generation); + assert_eq!(claim.acquired_at_unix_ms(), 10); + assert_eq!(claim.expires_at_unix_ms(), 20); + assert_eq!(claim.row_revision(), revision); + assert!(claim.matches_fence(&[9; 16], generation, revision, 10)); + assert!(claim.matches_fence(&[9; 16], generation, revision, 19)); + assert!(!claim.matches_fence(&[8; 16], generation, revision, 10)); + assert!(!claim.matches_fence(&[9; 16], NonZeroU64::MIN, revision, 10)); + assert!(!claim.matches_fence(&[9; 16], generation, NonZeroU64::MIN, 10)); + assert!(!claim.matches_fence(&[9; 16], generation, revision, 9)); + assert!(!claim.matches_fence(&[9; 16], generation, revision, 20)); + + for (token, owner, acquired, expires) in [ + ([0; 16], "worker".to_owned(), 10, 20), + ([1; 16], String::new(), 10, 20), + ([1; 16], " worker".to_owned(), 10, 20), + ([1; 16], "worker ".to_owned(), 10, 20), + ([1; 16], "worker\nname".to_owned(), 10, 20), + ([1; 16], "x".repeat(WORK_CLAIM_OWNER_MAX_BYTES + 1), 10, 20), + ([1; 16], "worker".to_owned(), 0, 20), + ([1; 16], "worker".to_owned(), 10, 10), + ([1; 16], "worker".to_owned(), 10, 9), + ] { + assert_eq!( + WorkClaim::new(token, owner, generation, acquired, expires, revision), + Err(Error::InvalidWorkClaim) + ); + } + + let json = serde_json::to_string(&claim).unwrap(); + assert_eq!(serde_json::from_str::<WorkClaim>(&json).unwrap(), claim); + let mut invalid: serde_json::Value = serde_json::from_str(&json).unwrap(); + invalid["owner"] = serde_json::json!(""); + assert!(serde_json::from_value::<WorkClaim>(invalid).is_err()); +} + +#[test] +fn failure_and_retry_models_cover_every_class_and_boundary() { + for phase in [ + WorkPhase::Signing, + WorkPhase::Admission, + WorkPhase::Delivery, + ] { + for class in [ + FailureClass::Retryable, + FailureClass::Terminal, + FailureClass::Indeterminate, + ] { + let retry_after = (class == FailureClass::Retryable).then_some(20); + let failure = WorkFailure::new( + "failure.code-1", + phase, + class, + retry_after, + Some("safe detail".to_owned()), + ) + .unwrap(); + assert_eq!(failure.code(), "failure.code-1"); + assert_eq!(failure.phase(), phase); + assert_eq!(failure.class(), class); + assert_eq!(failure.retry_after_unix_ms(), retry_after); + assert_eq!(failure.diagnostic(), Some("safe detail")); + failure.validate().unwrap(); + assert_eq!( + serde_json::from_str::<WorkFailure>(&serde_json::to_string(&failure).unwrap()) + .unwrap(), + failure + ); + } + } + + for (code, class, retry_after, diagnostic) in [ + ("", FailureClass::Terminal, None, None), + ("INVALID", FailureClass::Terminal, None, None), + ("bad/code", FailureClass::Terminal, None, None), + ( + "x".repeat(WORK_FAILURE_CODE_MAX_BYTES + 1).leak(), + FailureClass::Terminal, + None, + None, + ), + ("failure", FailureClass::Retryable, Some(0), None), + ("failure", FailureClass::Terminal, Some(20), None), + ("failure", FailureClass::Indeterminate, Some(20), None), + ("failure", FailureClass::Terminal, None, Some("".to_owned())), + ( + "failure", + FailureClass::Terminal, + None, + Some(" diagnostic".to_owned()), + ), + ( + "failure", + FailureClass::Terminal, + None, + Some("x".repeat(WORK_FAILURE_DIAGNOSTIC_MAX_BYTES + 1)), + ), + ] { + assert_eq!( + WorkFailure::new(code, WorkPhase::Signing, class, retry_after, diagnostic), + Err(Error::InvalidWorkFailure) + ); + } + + let initial_failure = retry_failure(WorkPhase::Delivery, 30); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, initial_failure.clone()).unwrap(); + assert_eq!(retry.attempt(), NonZeroU32::MIN); + assert_eq!(retry.not_before_unix_ms(), 30); + assert_eq!(retry.failure(), &initial_failure); + let next_failure = retry_failure(WorkPhase::Delivery, 40); + assert_eq!( + retry + .next_attempt(40, next_failure) + .unwrap() + .attempt() + .get(), + 2 + ); + assert_eq!( + RetrySchedule::new(NonZeroU32::MIN, 0, initial_failure.clone()), + Err(Error::InvalidRetrySchedule) + ); + assert_eq!( + RetrySchedule::new( + NonZeroU32::MIN, + 30, + WorkFailure::new( + "terminal", + WorkPhase::Delivery, + FailureClass::Terminal, + None, + None, + ) + .unwrap(), + ), + Err(Error::InvalidRetrySchedule) + ); + assert_eq!( + RetrySchedule::new(NonZeroU32::MIN, 31, initial_failure), + Err(Error::InvalidRetrySchedule) + ); + + let mut maximum = serde_json::to_value(&retry).unwrap(); + maximum["attempt"] = serde_json::json!(u32::MAX); + let maximum: RetrySchedule = serde_json::from_value(maximum).unwrap(); + assert_eq!( + maximum.next_attempt(40, retry_failure(WorkPhase::Delivery, 40)), + Err(Error::InvalidRetrySchedule) + ); +} + +#[test] +fn operation_construction_reconstruction_and_accessors_are_bounded() { + assert_eq!( + AuthoredArtifactId::new([0; 16]), + Err(Error::InvalidAuthoredArtifact) + ); + let id = artifact_id(2); + assert_eq!(id.as_bytes(), &[2; 16]); + assert_eq!(AuthoredArtifactId::try_from([2; 16]).unwrap(), id); + assert_eq!(<[u8; 16]>::from(id), [2; 16]); + + let operation = AuthoredOperation::new(operation_id(), vec![id], 10).unwrap(); + assert_eq!(operation.operation_id(), operation_id()); + assert_eq!(operation.artifact_ids(), &[id]); + assert_eq!(operation.created_at_unix_ms(), 10); + assert_eq!(operation.updated_at_unix_ms(), 10); + assert_eq!(operation.revision(), NonZeroU64::MIN); + assert_eq!( + AuthoredOperation::reconstruct( + operation_id(), + vec![id], + 10, + 11, + NonZeroU64::new(2).unwrap(), + ) + .unwrap() + .updated_at_unix_ms(), + 11 + ); + + for (ids, created, updated) in [ + (Vec::new(), 10, 10), + (vec![id, id], 10, 10), + ( + (0..=AUTHORED_OPERATION_ARTIFACTS_MAX) + .map(|index| artifact_id((index % 254 + 1) as u8)) + .collect(), + 10, + 10, + ), + (vec![id], 0, 10), + (vec![id], 10, 9), + ] { + assert_eq!( + AuthoredOperation::reconstruct(operation_id(), ids, created, updated, NonZeroU64::MIN,), + Err(Error::InvalidAuthoredOperation) + ); + } +} + +fn claim_for(artifact: &AuthoredArtifact, token: u8, generation: u64, acquired: u64) -> WorkClaim { + WorkClaim::new( + [token; 16], + format!("worker-{token}"), + NonZeroU64::new(generation).unwrap(), + acquired, + acquired + 10, + artifact.revision(), + ) + .unwrap() +} + +fn failure(phase: WorkPhase, class: FailureClass, at: Option<u64>) -> WorkFailure { + WorkFailure::new("operation_failure", phase, class, at, None).unwrap() +} + +#[test] +fn signing_claim_failure_terminal_indeterminate_and_cancel_paths_are_fenced() { + let authored_plan = plan(); + let mut retryable = + AuthoredArtifact::planned(artifact_id(10), operation_id(), 0, &authored_plan, 10).unwrap(); + let first_claim = claim_for(&retryable, 1, 1, 11); + retryable + .set_signing_claim(first_claim.clone(), 11) + .unwrap(); + assert_eq!(retryable.signing_claim(), Some(&first_claim)); + assert_eq!( + retryable.set_signing_claim(claim_for(&retryable, 2, 2, 12), 12), + Err(Error::InvalidAuthoredTransition) + ); + let replacement = claim_for(&retryable, 3, 2, 21); + retryable + .set_signing_claim(replacement.clone(), 21) + .unwrap(); + assert_eq!(retryable.signing_claim(), Some(&replacement)); + + let retry_failure = failure(WorkPhase::Signing, FailureClass::Retryable, Some(40)); + let retry = RetrySchedule::new(NonZeroU32::MIN, 40, retry_failure.clone()).unwrap(); + retryable + .record_signing_failure(retry_failure.clone(), Some(retry.clone()), 22) + .unwrap(); + assert_eq!(retryable.signing_state(), SigningState::Retryable); + assert_eq!(retryable.signing_retry(), Some(&retry)); + assert_eq!(retryable.last_failure(), Some(&retry_failure)); + assert_eq!( + retryable.set_signing_claim(claim_for(&retryable, 4, 3, 39), 39), + Err(Error::InvalidAuthoredTransition) + ); + let retry_claim = claim_for(&retryable, 4, 3, 40); + retryable.set_signing_claim(retry_claim, 40).unwrap(); + retryable.record_signed(signed(&authored_plan), 41).unwrap(); + assert_eq!(retryable.signing_state(), SigningState::Signed); + assert_eq!(retryable.signing_retry(), None); + assert_eq!(retryable.last_failure(), None); + + let mut terminal = + AuthoredArtifact::planned(artifact_id(11), operation_id(), 0, &authored_plan, 10).unwrap(); + let terminal_failure = failure(WorkPhase::Signing, FailureClass::Terminal, None); + terminal + .record_signing_failure(terminal_failure.clone(), None, 11) + .unwrap(); + assert_eq!(terminal.signing_state(), SigningState::FailedTerminal); + assert_eq!(terminal.last_failure(), Some(&terminal_failure)); + assert_eq!( + terminal.cancel_signing(12), + Err(Error::InvalidAuthoredTransition) + ); + + let mut indeterminate = + AuthoredArtifact::planned(artifact_id(12), operation_id(), 0, &authored_plan, 10).unwrap(); + let indeterminate_failure = failure(WorkPhase::Signing, FailureClass::Indeterminate, None); + indeterminate + .record_signing_failure(indeterminate_failure.clone(), None, 11) + .unwrap(); + assert_eq!(indeterminate.signing_state(), SigningState::Indeterminate); + + let mut cancelled = + AuthoredArtifact::planned(artifact_id(13), operation_id(), 0, &authored_plan, 10).unwrap(); + cancelled.cancel_signing(11).unwrap(); + assert_eq!(cancelled.signing_state(), SigningState::Cancelled); + assert_eq!(cancelled.updated_at_unix_ms(), 11); + + let mut imported = AuthoredArtifact::imported_signed( + artifact_id(14), + operation_id(), + 0, + signed(&authored_plan), + 10, + ) + .unwrap(); + assert_eq!( + imported.record_signed(signed(&authored_plan), 11), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!( + imported.cancel_signing(11), + Err(Error::InvalidAuthoredTransition) + ); + + let mut invalid = + AuthoredArtifact::planned(artifact_id(15), operation_id(), 0, &authored_plan, 10).unwrap(); + assert_eq!( + invalid.record_signing_failure( + failure(WorkPhase::Admission, FailureClass::Terminal, None), + None, + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!( + invalid.record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Retryable, Some(20)), + None, + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!( + invalid.record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal, None), + Some( + RetrySchedule::new( + NonZeroU32::MIN, + 20, + failure(WorkPhase::Signing, FailureClass::Retryable, Some(20)), + ) + .unwrap(), + ), + 11, + ), + Err(Error::InvalidAuthoredTransition) + ); + let before = invalid.clone(); + assert_eq!( + invalid.cancel_signing(9), + Err(Error::InvalidAuthoredTransition) + ); + assert_eq!(invalid, before); +} + +fn signed_artifact(value: u8) -> AuthoredArtifact { + let authored_plan = plan(); + let mut artifact = + AuthoredArtifact::planned(artifact_id(value), operation_id(), 0, &authored_plan, 10) + .unwrap(); + artifact.record_signed(signed(&authored_plan), 11).unwrap(); + artifact +} + +#[test] +fn admission_claim_and_result_paths_enforce_failure_coherence() { + let mut inserted = signed_artifact(20); + let active = claim_for(&inserted, 1, 1, 12); + inserted.set_admission_claim(active.clone(), 12).unwrap(); + assert_eq!(inserted.admission_claim(), Some(&active)); + inserted + .record_admission(AdmissionState::Inserted, None, None, 13) + .unwrap(); + assert_eq!(inserted.admission_state(), AdmissionState::Inserted); + assert!(inserted.admission_state().is_admitted()); + assert_eq!(inserted.admission_claim(), None); + + let mut duplicate = signed_artifact(21); + duplicate + .record_admission(AdmissionState::Duplicate, None, None, 12) + .unwrap(); + assert!(duplicate.admission_state().is_admitted()); + + let mut retryable = signed_artifact(22); + let retry_failure = failure(WorkPhase::Admission, FailureClass::Retryable, Some(30)); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, retry_failure.clone()).unwrap(); + retryable + .record_admission( + AdmissionState::Retryable, + Some(retry_failure.clone()), + Some(retry.clone()), + 12, + ) + .unwrap(); + assert_eq!(retryable.admission_retry(), Some(&retry)); + assert_eq!(retryable.last_failure(), Some(&retry_failure)); + assert_eq!( + retryable.set_admission_claim(claim_for(&retryable, 2, 2, 29), 29), + Err(Error::InvalidAuthoredTransition) + ); + let active = claim_for(&retryable, 2, 2, 30); + retryable.set_admission_claim(active, 30).unwrap(); + + for (value, state) in [ + (23, AdmissionState::Rejected), + (24, AdmissionState::Cancelled), + ] { + let mut artifact = signed_artifact(value); + let terminal = failure(WorkPhase::Admission, FailureClass::Terminal, None); + artifact + .record_admission(state, Some(terminal.clone()), None, 12) + .unwrap(); + assert_eq!(artifact.admission_state(), state); + assert_eq!(artifact.last_failure(), Some(&terminal)); + } + + let invalid_cases = [ + (AdmissionState::Pending, None, None), + ( + AdmissionState::Inserted, + Some(failure(WorkPhase::Admission, FailureClass::Terminal, None)), + None, + ), + (AdmissionState::Rejected, None, None), + ( + AdmissionState::Retryable, + Some(failure( + WorkPhase::Admission, + FailureClass::Retryable, + Some(30), + )), + None, + ), + ( + AdmissionState::Rejected, + Some(failure(WorkPhase::Signing, FailureClass::Terminal, None)), + None, + ), + ]; + for (index, (state, failure, retry)) in invalid_cases.into_iter().enumerate() { + let mut artifact = signed_artifact(30 + index as u8); + assert_eq!( + artifact.record_admission(state, failure, retry, 12), + Err(Error::InvalidAuthoredTransition) + ); + } + + let mut unsigned = + AuthoredArtifact::planned(artifact_id(40), operation_id(), 0, &plan(), 10).unwrap(); + assert_eq!( + unsigned.set_admission_claim(claim_for(&unsigned, 1, 1, 11), 11), + Err(Error::InvalidAuthoredTransition) + ); +} + +fn assert_artifact_json_rejected( + mut value: serde_json::Value, + key: &str, + replacement: serde_json::Value, +) { + value[key] = replacement; + assert!( + serde_json::from_value::<AuthoredArtifact>(value).is_err(), + "forged artifact field {key} was accepted" + ); +} + +#[test] +fn authored_artifact_reconstruction_rejects_each_incoherent_durable_state() { + let authored_plan = plan(); + let planned = + AuthoredArtifact::planned(artifact_id(50), operation_id(), 0, &authored_plan, 10).unwrap(); + let planned_value = serde_json::to_value(&planned).unwrap(); + assert_artifact_json_rejected( + planned_value.clone(), + "created_at_unix_ms", + serde_json::json!(0), + ); + assert_artifact_json_rejected( + planned_value.clone(), + "updated_at_unix_ms", + serde_json::json!(9), + ); + assert_artifact_json_rejected(planned_value.clone(), "plan", serde_json::Value::Null); + assert_artifact_json_rejected( + planned_value.clone(), + "signing_state", + serde_json::json!("signed"), + ); + assert_artifact_json_rejected( + planned_value.clone(), + "admission_state", + serde_json::json!("inserted"), + ); + assert_artifact_json_rejected( + planned_value.clone(), + "signing_state", + serde_json::json!("retryable"), + ); + let mut admission_without_signed = planned_value.clone(); + admission_without_signed["admission_state"] = serde_json::json!("retryable"); + assert!(serde_json::from_value::<AuthoredArtifact>(admission_without_signed).is_err()); + + let imported = AuthoredArtifact::imported_signed( + artifact_id(51), + operation_id(), + 0, + signed(&authored_plan), + 10, + ) + .unwrap(); + let imported_value = serde_json::to_value(&imported).unwrap(); + let mut imported_with_plan = imported_value.clone(); + imported_with_plan["plan"] = planned_value["plan"].clone(); + assert!(serde_json::from_value::<AuthoredArtifact>(imported_with_plan).is_err()); + assert_artifact_json_rejected( + imported_value.clone(), + "signing_state", + serde_json::json!("planned"), + ); + assert_artifact_json_rejected(imported_value.clone(), "signed", serde_json::Value::Null); + + let mut claimed = planned.clone(); + let active = claim_for(&claimed, 1, 1, 11); + claimed.set_signing_claim(active, 11).unwrap(); + let claimed_value = serde_json::to_value(&claimed).unwrap(); + assert_artifact_json_rejected(claimed_value.clone(), "revision", serde_json::json!(9)); + assert_artifact_json_rejected( + claimed_value.clone(), + "updated_at_unix_ms", + serde_json::json!(12), + ); + assert_artifact_json_rejected( + claimed_value.clone(), + "signing_state", + serde_json::json!("cancelled"), + ); + + let mut signing_retry = planned.clone(); + let signing_failure = failure(WorkPhase::Signing, FailureClass::Retryable, Some(20)); + signing_retry + .record_signing_failure( + signing_failure.clone(), + Some(RetrySchedule::new(NonZeroU32::MIN, 20, signing_failure).unwrap()), + 11, + ) + .unwrap(); + let retry_value = serde_json::to_value(&signing_retry).unwrap(); + let mut wrong_retry_phase = retry_value.clone(); + wrong_retry_phase["signing_retry"]["failure"]["phase"] = serde_json::json!("admission"); + assert!(serde_json::from_value::<AuthoredArtifact>(wrong_retry_phase).is_err()); + assert_artifact_json_rejected(retry_value.clone(), "last_failure", serde_json::Value::Null); + + let mut signed_pending = planned.clone(); + signed_pending + .record_signed(signed(&authored_plan), 11) + .unwrap(); + let signed_value = serde_json::to_value(&signed_pending).unwrap(); + let mut admission_claimed = signed_pending.clone(); + let active = claim_for(&admission_claimed, 2, 2, 12); + admission_claimed.set_admission_claim(active, 12).unwrap(); + let admission_claimed_value = serde_json::to_value(&admission_claimed).unwrap(); + assert_artifact_json_rejected( + admission_claimed_value.clone(), + "revision", + serde_json::json!(9), + ); + assert_artifact_json_rejected( + admission_claimed_value.clone(), + "updated_at_unix_ms", + serde_json::json!(13), + ); + assert_artifact_json_rejected( + admission_claimed_value.clone(), + "admission_state", + serde_json::json!("inserted"), + ); + let mut claim_without_signed = admission_claimed_value; + claim_without_signed["signing_state"] = serde_json::json!("planned"); + claim_without_signed["signed"] = serde_json::Value::Null; + assert!(serde_json::from_value::<AuthoredArtifact>(claim_without_signed).is_err()); + + let admission_failure = failure(WorkPhase::Admission, FailureClass::Retryable, Some(30)); + let mut admission_retry = signed_pending.clone(); + admission_retry + .record_admission( + AdmissionState::Retryable, + Some(admission_failure.clone()), + Some(RetrySchedule::new(NonZeroU32::MIN, 30, admission_failure).unwrap()), + 12, + ) + .unwrap(); + let mut wrong_admission_phase = serde_json::to_value(admission_retry).unwrap(); + wrong_admission_phase["admission_retry"]["failure"]["phase"] = serde_json::json!("signing"); + assert!(serde_json::from_value::<AuthoredArtifact>(wrong_admission_phase).is_err()); + + let terminal = failure(WorkPhase::Admission, FailureClass::Terminal, None); + let mut unexpected_failure = signed_value; + unexpected_failure["last_failure"] = serde_json::to_value(terminal).unwrap(); + assert!(serde_json::from_value::<AuthoredArtifact>(unexpected_failure).is_err()); +} + +#[test] +fn authored_transition_guards_reject_every_independent_stale_or_incoherent_input() { + let authored_plan = plan(); + let mut signing = + AuthoredArtifact::planned(artifact_id(60), operation_id(), 0, &authored_plan, 10).unwrap(); + let active = claim_for(&signing, 1, 2, 11); + signing.set_signing_claim(active, 11).unwrap(); + let lower_generation = claim_for(&signing, 2, 1, 21); + assert_eq!( + signing.set_signing_claim(lower_generation, 21), + Err(Error::InvalidAuthoredTransition) + ); + let wrong_revision = WorkClaim::new( + [3; 16], + "worker", + NonZeroU64::new(3).unwrap(), + 21, + 30, + NonZeroU64::MIN, + ) + .unwrap(); + assert_eq!( + signing.set_signing_claim(wrong_revision, 21), + Err(Error::InvalidAuthoredTransition) + ); + let wrong_time = WorkClaim::new( + [4; 16], + "worker", + NonZeroU64::new(4).unwrap(), + 22, + 30, + signing.revision(), + ) + .unwrap(); + assert_eq!( + signing.set_signing_claim(wrong_time, 21), + Err(Error::InvalidAuthoredTransition) + ); + + let mut admission = signed_artifact(61); + let active = claim_for(&admission, 5, 2, 12); + admission.set_admission_claim(active, 12).unwrap(); + let lower_generation = claim_for(&admission, 6, 1, 22); + assert_eq!( + admission.set_admission_claim(lower_generation, 22), + Err(Error::InvalidAuthoredTransition) + ); + let wrong_revision = WorkClaim::new( + [7; 16], + "worker", + NonZeroU64::new(3).unwrap(), + 22, + 30, + NonZeroU64::MIN, + ) + .unwrap(); + assert_eq!( + admission.set_admission_claim(wrong_revision, 22), + Err(Error::InvalidAuthoredTransition) + ); + let wrong_time = WorkClaim::new( + [8; 16], + "worker", + NonZeroU64::new(4).unwrap(), + 23, + 30, + admission.revision(), + ) + .unwrap(); + assert_eq!( + admission.set_admission_claim(wrong_time, 22), + Err(Error::InvalidAuthoredTransition) + ); + + let invalid_retry_failure = failure(WorkPhase::Admission, FailureClass::Retryable, Some(30)); + let other_retry_failure = WorkFailure::new( + "other_failure", + WorkPhase::Admission, + FailureClass::Retryable, + Some(30), + None, + ) + .unwrap(); + let other_retry = RetrySchedule::new(NonZeroU32::MIN, 30, other_retry_failure).unwrap(); + for (state, failure, retry) in [ + ( + AdmissionState::Retryable, + Some(invalid_retry_failure.clone()), + Some(other_retry), + ), + ( + AdmissionState::Retryable, + Some(failure(WorkPhase::Admission, FailureClass::Terminal, None)), + Some(RetrySchedule::new(NonZeroU32::MIN, 30, invalid_retry_failure.clone()).unwrap()), + ), + ( + AdmissionState::Duplicate, + Some(failure(WorkPhase::Admission, FailureClass::Terminal, None)), + None, + ), + ( + AdmissionState::Cancelled, + Some(failure(WorkPhase::Admission, FailureClass::Terminal, None)), + Some(RetrySchedule::new(NonZeroU32::MIN, 30, invalid_retry_failure.clone()).unwrap()), + ), + ] { + let mut artifact = signed_artifact(70); + assert_eq!( + artifact.record_admission(state, failure, retry, 12), + Err(Error::InvalidAuthoredTransition) + ); + } +} + +fn delivery_request() -> DeliveryRequest { + let targets = TargetSet::new(vec![ + Target::nostr_relay("wss://one.example").unwrap(), + Target::nostr_relay("wss://two.example").unwrap(), + ]) + .unwrap(); + DeliveryRequest::new( + "settlement-delivery", + DeliveryPayload::new(signed(&plan())), + targets, + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + 100, + ) + .unwrap() +} + +fn settlement_plan(value: u8, artifact: AuthoredArtifactId) -> AuthoredDeliveryPlan { + AuthoredDeliveryPlan::new_bound( + AuthoredDeliveryPlanId::new([value; 16]).unwrap(), + artifact, + delivery_request(), + 10, + ) + .unwrap() +} + +fn claim_delivery(plan: &AuthoredDeliveryPlan, token: u8) -> WorkClaim { + WorkClaim::new( + [token; 16], + "delivery-worker", + NonZeroU64::new(u64::from(token)).unwrap(), + 11, + 20, + plan.revision(), + ) + .unwrap() +} + +fn delivery_receipt(plan: &AuthoredDeliveryPlan, outcome: DeliveryOutcome) -> DeliveryReceipt { + let request = plan.request().unwrap(); + DeliveryReceipt::for_request( + request, + request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| DeliveryTargetReceipt::attempted(target, outcome.clone())) + .collect(), + ) + .unwrap() +} + +#[test] +fn settlement_counts_every_artifact_and_delivery_terminal_class() { + let authored_plan = plan(); + let mut artifacts = Vec::new(); + for ordinal in 0_u16..11 { + artifacts.push( + AuthoredArtifact::planned( + artifact_id(80 + ordinal as u8), + operation_id(), + ordinal, + &authored_plan, + 10, + ) + .unwrap(), + ); + } + let signing_retry = failure(WorkPhase::Signing, FailureClass::Retryable, Some(20)); + artifacts[1] + .record_signing_failure( + signing_retry.clone(), + Some(RetrySchedule::new(NonZeroU32::MIN, 20, signing_retry).unwrap()), + 11, + ) + .unwrap(); + artifacts[2] + .record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Indeterminate, None), + None, + 11, + ) + .unwrap(); + artifacts[3] + .record_signing_failure( + failure(WorkPhase::Signing, FailureClass::Terminal, None), + None, + 11, + ) + .unwrap(); + artifacts[4].cancel_signing(11).unwrap(); + for artifact in &mut artifacts[5..] { + artifact.record_signed(signed(&authored_plan), 11).unwrap(); + } + let admission_retry = failure(WorkPhase::Admission, FailureClass::Retryable, Some(20)); + artifacts[6] + .record_admission( + AdmissionState::Retryable, + Some(admission_retry.clone()), + Some(RetrySchedule::new(NonZeroU32::MIN, 20, admission_retry).unwrap()), + 12, + ) + .unwrap(); + for (index, state) in [ + (7, AdmissionState::Rejected), + (8, AdmissionState::Cancelled), + ] { + artifacts[index] + .record_admission( + state, + Some(failure(WorkPhase::Admission, FailureClass::Terminal, None)), + None, + 12, + ) + .unwrap(); + } + artifacts[9] + .record_admission(AdmissionState::Inserted, None, None, 12) + .unwrap(); + artifacts[10] + .record_admission(AdmissionState::Duplicate, None, None, 12) + .unwrap(); + + let operation = AuthoredOperation::new( + operation_id(), + artifacts + .iter() + .map(AuthoredArtifact::artifact_id) + .collect(), + 10, + ) + .unwrap(); + let artifact = artifacts[9].artifact_id(); + let mut plans: Vec<_> = (100_u8..106) + .map(|value| settlement_plan(value, artifact)) + .collect(); + let active = claim_delivery(&plans[1], 1); + plans[1].claim(active.clone(), 11).unwrap(); + let pending = delivery_receipt(&plans[1], DeliveryOutcome::unavailable()); + let retry_failure = failure(WorkPhase::Delivery, FailureClass::Retryable, Some(20)); + plans[1] + .apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + pending, + Some(RetrySchedule::new(NonZeroU32::MIN, 20, retry_failure).unwrap()), + 12, + ) + .unwrap(); + let active = claim_delivery(&plans[2], 2); + plans[2].claim(active.clone(), 11).unwrap(); + let accepted = delivery_receipt(&plans[2], DeliveryOutcome::accepted()); + plans[2] + .apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + accepted, + None, + 12, + ) + .unwrap(); + let active = claim_delivery(&plans[3], 3); + plans[3].claim(active.clone(), 11).unwrap(); + let rejected = delivery_receipt(&plans[3], DeliveryOutcome::rejected()); + plans[3] + .apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + rejected, + None, + 12, + ) + .unwrap(); + let active = claim_delivery(&plans[4], 4); + plans[4].claim(active.clone(), 11).unwrap(); + let request = plans[4].request().unwrap(); + let terminal = SinkFailure::for_request( + request, + "terminal", + Retryability::Terminal, + None, + None, + Vec::new(), + ) + .unwrap(); + plans[4] + .apply_sink_failure( + active.token(), + active.generation(), + active.row_revision(), + terminal, + None, + 12, + ) + .unwrap(); + plans[5].cancel(11).unwrap(); + + let settlement = + OperationSettlement::evaluate_complete(&operation, &artifacts, &plans).unwrap(); + assert_eq!(settlement.artifacts(), 11); + assert_eq!(settlement.signed(), 6); + assert_eq!(settlement.admitted(), 2); + assert_eq!(settlement.pending(), 2); + assert_eq!(settlement.retryable(), 2); + assert_eq!(settlement.indeterminate(), 1); + assert_eq!(settlement.failed_terminal(), 2); + assert_eq!(settlement.cancelled(), 2); + assert_eq!(settlement.delivery_plans(), 6); + assert_eq!(settlement.delivery_satisfied(), 1); + assert_eq!(settlement.delivery_pending(), 1); + assert_eq!(settlement.delivery_retryable(), 1); + assert_eq!(settlement.delivery_exhausted(), 1); + assert_eq!(settlement.delivery_failed_terminal(), 1); + assert_eq!(settlement.delivery_cancelled(), 1); + assert!(!settlement.is_settled()); + assert!(settlement.has_failures()); + assert!(!settlement.is_successful()); + + assert_eq!( + OperationSettlement::evaluate_complete( + &operation, + &artifacts, + &[plans[0].clone(), plans[0].clone()] + ), + Err(Error::InvalidAuthoredOperation) + ); + let foreign = settlement_plan(110, artifact_id(120)); + assert_eq!( + OperationSettlement::evaluate_complete(&operation, &artifacts, &[foreign]), + Err(Error::InvalidAuthoredOperation) + ); +} diff --git a/crates/storage/tests/authored_atomic.rs b/crates/storage/tests/authored_atomic.rs @@ -6,13 +6,14 @@ use radroots_storage::{ Error, atomic::{AtomicCommitDigest, AtomicCommitDisposition}, authored::{ - AuthoredArtifact, AuthoredArtifactId, AuthoredOperation, FailureClass, RetrySchedule, - SigningState, WorkClaim, WorkFailure, WorkPhase, + AdmissionState, AuthoredArtifact, AuthoredArtifactId, AuthoredOperation, FailureClass, + RetrySchedule, SigningState, WorkClaim, WorkFailure, WorkPhase, }, authored_atomic::{ - ApplyDeliveryAttempt, ApplySignedArtifact, AuthoredAtomicCommand, AuthoredAtomicOutcome, - AuthoredAtomicStorage, AuthoredWorkTarget, ClaimAuthoredTarget, ClaimAuthoredWork, - PrepareAuthoredOperation, WorkFence, + ApplyAdmissionResult, ApplyDeliveryAttempt, ApplySignedArtifact, ApplyWorkFailure, + AuthoredAtomicCommand, AuthoredAtomicOutcome, AuthoredAtomicReceipt, AuthoredAtomicStorage, + AuthoredWorkTarget, CancelAuthoredTarget, CancelAuthoredWork, ClaimAuthoredTarget, + ClaimAuthoredWork, PrepareAuthoredOperation, WorkFence, }, authored_delivery::{ AuthoredDeliveryIntent, AuthoredDeliveryPlan, AuthoredDeliveryPlanId, @@ -23,8 +24,8 @@ use radroots_storage::{ memory::MemoryStorage, }; use radroots_transport::{ - DeliveryReceipt, Target, TargetSet, - outcome::DeliveryOutcome, + DeliveryReceipt, SinkFailure, Target, TargetSet, + outcome::{DeliveryOutcome, Retryability}, policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, sink::DeliveryTargetReceipt, }; @@ -127,6 +128,62 @@ fn claim( ) } +fn fence(claim: &WorkClaim) -> WorkFence { + WorkFence::new(*claim.token(), claim.generation(), claim.row_revision()).unwrap() +} + +fn prepared_storage() -> (MemoryStorage, AuthoredEventPlan) { + let storage = MemoryStorage::new(SourceGeneration::new([1; 32]).unwrap()); + let (prepare, plan) = prepare(7); + block_on(storage.execute_authored(prepare)).unwrap(); + (storage, plan) +} + +fn signed_storage() -> MemoryStorage { + let (storage, plan) = prepared_storage(); + let artifact = block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap(); + let (command, active) = claim( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + artifact.revision(), + 4, + 11, + ); + block_on(storage.execute_authored(command)).unwrap(); + let apply = ApplySignedArtifact::new(ids().1, fence(&active), signed(&plan), 12).unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplySigned(apply))).unwrap(); + storage +} + +fn claim_admission(storage: &MemoryStorage, token: u8, at: u64) -> WorkClaim { + let artifact = block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap(); + let (command, active) = claim( + ClaimAuthoredTarget::ArtifactAdmission(ids().1), + artifact.revision(), + token, + at, + ); + block_on(storage.execute_authored(command)).unwrap(); + active +} + +fn claim_delivery(storage: &MemoryStorage, token: u8, at: u64) -> WorkClaim { + let plan = block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap(); + let (command, active) = claim( + ClaimAuthoredTarget::DeliveryPlan(ids().2), + plan.revision(), + token, + at, + ); + block_on(storage.execute_authored(command)).unwrap(); + active +} + #[test] fn preparation_is_atomic_deterministic_and_exactly_replayable() { let storage = MemoryStorage::new(SourceGeneration::new([1; 32]).expect("generation")); @@ -336,3 +393,626 @@ fn delivery_attempt_and_work_failure_commands_preserve_atomic_state() { before ); } + +#[test] +fn authored_atomic_command_models_cover_every_phase_identity_and_durable_outcome() { + assert_eq!( + WorkFence::new([0; 16], NonZeroU64::MIN, NonZeroU64::MIN), + Err(Error::InvalidWorkClaim) + ); + let fence = + WorkFence::new([7; 16], NonZeroU64::new(2).unwrap(), NonZeroU64::MIN).expect("fence"); + assert_eq!(fence.token(), &[7; 16]); + assert_eq!(fence.generation(), NonZeroU64::new(2).unwrap()); + assert_eq!(fence.row_revision(), NonZeroU64::MIN); + + let (prepared_command, authored_plan) = prepare(7); + let AuthoredAtomicCommand::Prepare(prepared) = prepared_command.clone() else { + unreachable!() + }; + assert_eq!(prepared.operation().operation_id(), ids().0); + assert_eq!(prepared.artifacts().len(), 1); + assert_eq!(prepared.delivery_plans().len(), 1); + assert_eq!(prepared.input_digest(), AtomicCommitDigest::new([7; 32])); + assert_eq!(prepared.requested_at_unix_ms(), 10); + + assert_eq!( + PrepareAuthoredOperation::new( + prepared.operation().clone(), + Vec::new(), + Vec::new(), + AtomicCommitDigest::new([1; 32]), + 10, + ), + Err(Error::AtomicWorkflowMismatch) + ); + assert_eq!( + PrepareAuthoredOperation::new( + prepared.operation().clone(), + prepared.artifacts().to_vec(), + prepared.delivery_plans().to_vec(), + AtomicCommitDigest::new([1; 32]), + 0, + ), + Err(Error::AtomicWorkflowMismatch) + ); + + let work_claim = + WorkClaim::new([8; 16], "worker", NonZeroU64::MIN, 11, 20, NonZeroU64::MIN).unwrap(); + let claim_commands = [ + AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + work_claim.clone(), + )), + AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactAdmission(ids().1), + work_claim.clone(), + )), + AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::DeliveryPlan(ids().2), + work_claim.clone(), + )), + ]; + for command in &claim_commands { + let AuthoredAtomicCommand::Claim(value) = command else { + unreachable!() + }; + assert_eq!(value.claim(), &work_claim); + let _ = value.target(); + } + + let apply_signed = ApplySignedArtifact::new(ids().1, fence.clone(), signed(&authored_plan), 12) + .expect("signed command"); + assert_eq!(apply_signed.artifact_id(), ids().1); + assert_eq!(apply_signed.fence(), &fence); + assert_eq!(apply_signed.event().id(), authored_plan.expected_event_id()); + assert_eq!(apply_signed.applied_at_unix_ms(), 12); + assert_eq!( + ApplySignedArtifact::new(ids().1, fence.clone(), signed(&authored_plan), 0), + Err(Error::AtomicWorkflowMismatch) + ); + + let retry_failure = WorkFailure::new( + "temporary_admission", + WorkPhase::Admission, + FailureClass::Retryable, + Some(30), + Some("try another worker".to_owned()), + ) + .unwrap(); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, retry_failure.clone()).unwrap(); + let apply_admission = ApplyAdmissionResult::new( + ids().1, + fence.clone(), + AdmissionState::Retryable, + Some(retry_failure.clone()), + Some(retry.clone()), + 13, + ) + .unwrap(); + assert_eq!(apply_admission.artifact_id(), ids().1); + assert_eq!(apply_admission.fence(), &fence); + assert_eq!(apply_admission.state(), AdmissionState::Retryable); + assert_eq!(apply_admission.failure(), Some(&retry_failure)); + assert_eq!(apply_admission.retry(), Some(&retry)); + assert_eq!(apply_admission.applied_at_unix_ms(), 13); + assert_eq!( + ApplyAdmissionResult::new( + ids().1, + fence.clone(), + AdmissionState::Inserted, + None, + None, + 0, + ), + Err(Error::AtomicWorkflowMismatch) + ); + + let request = intent() + .materialize(radroots_transport::sink::DeliveryPayload::new(signed( + &authored_plan, + ))) + .unwrap(); + let receipt = DeliveryReceipt::for_request( + &request, + request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| { + DeliveryTargetReceipt::attempted( + target, + DeliveryOutcome::accepted() + .with_detail("accepted", "accepted by relay") + .unwrap(), + ) + }) + .collect(), + ) + .unwrap(); + let apply_delivery = ApplyDeliveryAttempt::new( + ids().2, + fence.clone(), + DeliveryAttemptOutcome::Receipt(receipt.clone()), + None, + 14, + ) + .unwrap(); + assert_eq!(apply_delivery.plan_id(), ids().2); + assert_eq!(apply_delivery.fence(), &fence); + assert!(matches!( + apply_delivery.outcome(), + DeliveryAttemptOutcome::Receipt(_) + )); + assert_eq!(apply_delivery.retry(), None); + assert_eq!(apply_delivery.applied_at_unix_ms(), 14); + assert_eq!( + ApplyDeliveryAttempt::new( + ids().2, + fence.clone(), + DeliveryAttemptOutcome::Receipt(receipt), + None, + 0, + ), + Err(Error::AtomicWorkflowMismatch) + ); + + let sink_failure = SinkFailure::for_request( + &request, + "relay_unavailable", + Retryability::Retryable, + Some(30), + None, + Vec::new(), + ) + .unwrap(); + let sink_command = AuthoredAtomicCommand::ApplyDelivery( + ApplyDeliveryAttempt::new( + ids().2, + fence.clone(), + DeliveryAttemptOutcome::SinkFailure(sink_failure), + Some(retry.clone()), + 14, + ) + .unwrap(), + ); + + let failure_commands = [ + (AuthoredWorkTarget::Artifact(ids().1), WorkPhase::Signing), + (AuthoredWorkTarget::Artifact(ids().1), WorkPhase::Admission), + ( + AuthoredWorkTarget::DeliveryPlan(ids().2), + WorkPhase::Delivery, + ), + ] + .map(|(target, phase)| { + let failure = WorkFailure::new( + "terminal_failure", + phase, + FailureClass::Terminal, + None, + Some("terminal".to_owned()), + ) + .unwrap(); + AuthoredAtomicCommand::ApplyFailure( + ApplyWorkFailure::new(target, fence.clone(), failure, None, 15).unwrap(), + ) + }); + for command in &failure_commands { + let AuthoredAtomicCommand::ApplyFailure(value) = command else { + unreachable!() + }; + let _ = value.target(); + assert_eq!(value.fence(), &fence); + assert_eq!(value.failure().class(), FailureClass::Terminal); + assert_eq!(value.retry(), None); + assert_eq!(value.applied_at_unix_ms(), 15); + } + assert_eq!( + ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence.clone(), + retry_failure, + Some(retry), + 0, + ), + Err(Error::AtomicWorkflowMismatch) + ); + + let cancel_commands = [ + CancelAuthoredTarget::ArtifactSigning(ids().1), + CancelAuthoredTarget::ArtifactAdmission(ids().1), + CancelAuthoredTarget::DeliveryPlan(ids().2), + ] + .map(|target| { + AuthoredAtomicCommand::Cancel(CancelAuthoredWork::new(target, NonZeroU64::MIN, 16).unwrap()) + }); + for command in &cancel_commands { + let AuthoredAtomicCommand::Cancel(value) = command else { + unreachable!() + }; + let _ = value.target(); + assert_eq!(value.expected_revision(), NonZeroU64::MIN); + assert_eq!(value.cancelled_at_unix_ms(), 16); + } + assert_eq!( + CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactSigning(ids().1), + NonZeroU64::MIN, + 0, + ), + Err(Error::AtomicWorkflowMismatch) + ); + + let mut commands = vec![ + prepared_command, + AuthoredAtomicCommand::ApplySigned(apply_signed), + AuthoredAtomicCommand::ApplyAdmission(apply_admission), + AuthoredAtomicCommand::ApplyDelivery(apply_delivery), + sink_command, + ]; + commands.extend(claim_commands); + commands.extend(failure_commands); + commands.extend(cancel_commands); + for command in commands { + assert_ne!(command.commit_id().as_bytes(), &[0; 16]); + assert_ne!(command.digest().as_bytes(), &[0; 32]); + assert_ne!(command.requested_at_unix_ms(), 0); + } + + let outcome = AuthoredAtomicOutcome::Prepared { + operation: prepared.operation().clone(), + artifacts: prepared.artifacts().to_vec(), + delivery_plans: prepared.delivery_plans().to_vec(), + }; + let command = AuthoredAtomicCommand::Prepare(prepared.clone()); + assert_eq!( + AuthoredAtomicReceipt::new( + &command, + AtomicCommitDisposition::Committed, + 9, + outcome.clone(), + ), + Err(Error::AtomicWorkflowMismatch) + ); + let receipt = AuthoredAtomicReceipt::new( + &command, + AtomicCommitDisposition::Committed, + 10, + outcome.clone(), + ) + .unwrap(); + assert_eq!(receipt.commit_id(), command.commit_id()); + assert_eq!(receipt.digest(), command.digest()); + assert_eq!(receipt.disposition(), AtomicCommitDisposition::Committed); + assert_eq!(receipt.committed_at_unix_ms(), 10); + assert_eq!(receipt.outcome(), &outcome); + assert_eq!( + AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + AtomicCommitDisposition::Replay, + 0, + outcome.clone(), + ), + Err(Error::AtomicWorkflowMismatch) + ); + assert!( + AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + AtomicCommitDisposition::Replay, + 11, + outcome, + ) + .is_ok() + ); + let invalid_outcome = AuthoredAtomicOutcome::Prepared { + operation: prepared.operation().clone(), + artifacts: Vec::new(), + delivery_plans: Vec::new(), + }; + assert_eq!( + AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + AtomicCommitDisposition::Replay, + 11, + invalid_outcome, + ), + Err(Error::AtomicWorkflowMismatch) + ); +} + +#[test] +fn memory_executes_admission_results_and_every_authored_failure_phase() { + let storage = signed_storage(); + let active = claim_admission(&storage, 5, 13); + let inserted = ApplyAdmissionResult::new( + ids().1, + fence(&active), + AdmissionState::Inserted, + None, + None, + 14, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyAdmission(inserted))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Inserted + ); + + let (storage, _) = prepared_storage(); + let artifact = block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap(); + let (command, active) = claim( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + artifact.revision(), + 4, + 11, + ); + block_on(storage.execute_authored(command)).unwrap(); + let failure = WorkFailure::new( + "terminal_signer_failure", + WorkPhase::Signing, + FailureClass::Terminal, + None, + None, + ) + .unwrap(); + let command = ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence(&active), + failure, + None, + 12, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyFailure(command))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .signing_state(), + SigningState::FailedTerminal + ); + + let storage = signed_storage(); + let active = claim_admission(&storage, 5, 13); + let failure = WorkFailure::new( + "temporary_admission_failure", + WorkPhase::Admission, + FailureClass::Retryable, + Some(30), + None, + ) + .unwrap(); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, failure.clone()).unwrap(); + let command = ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence(&active), + failure, + Some(retry), + 14, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyFailure(command))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Retryable + ); + + let storage = signed_storage(); + let active = claim_admission(&storage, 5, 13); + let failure = WorkFailure::new( + "terminal_admission_failure", + WorkPhase::Admission, + FailureClass::Terminal, + None, + None, + ) + .unwrap(); + let command = ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence(&active), + failure, + None, + 14, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyFailure(command))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Rejected + ); +} + +#[test] +fn memory_executes_delivery_failures_and_rejects_phase_mismatches_atomically() { + let storage = signed_storage(); + let active = claim_delivery(&storage, 5, 13); + let failure = WorkFailure::new( + "relay_unavailable", + WorkPhase::Delivery, + FailureClass::Retryable, + Some(30), + Some("relay unavailable".to_owned()), + ) + .unwrap(); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, failure.clone()).unwrap(); + let command = ApplyWorkFailure::new( + AuthoredWorkTarget::DeliveryPlan(ids().2), + fence(&active), + failure, + Some(retry), + 14, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyFailure(command))).unwrap(); + assert_eq!( + block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::Retryable + ); + + let storage = signed_storage(); + let active = claim_delivery(&storage, 5, 13); + let request = block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap() + .request() + .unwrap() + .clone(); + let sink_failure = SinkFailure::for_request( + &request, + "relay_terminal", + Retryability::Terminal, + None, + Some("terminal".to_owned()), + Vec::new(), + ) + .unwrap(); + let command = ApplyDeliveryAttempt::new( + ids().2, + fence(&active), + DeliveryAttemptOutcome::SinkFailure(sink_failure), + None, + 14, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyDelivery(command))).unwrap(); + assert_eq!( + block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::FailedTerminal + ); + + for (target, phase, class) in [ + ( + AuthoredWorkTarget::Artifact(ids().1), + WorkPhase::Delivery, + FailureClass::Terminal, + ), + ( + AuthoredWorkTarget::DeliveryPlan(ids().2), + WorkPhase::Admission, + FailureClass::Terminal, + ), + ( + AuthoredWorkTarget::DeliveryPlan(ids().2), + WorkPhase::Delivery, + FailureClass::Indeterminate, + ), + ] { + let (storage, _) = prepared_storage(); + let failure = WorkFailure::new("mismatch", phase, class, None, None).unwrap(); + let command = ApplyWorkFailure::new( + target, + WorkFence::new([9; 16], NonZeroU64::MIN, NonZeroU64::MIN).unwrap(), + failure, + None, + 12, + ) + .unwrap(); + assert!( + block_on(storage.execute_authored(AuthoredAtomicCommand::ApplyFailure(command))) + .is_err() + ); + } +} + +#[test] +fn memory_executes_all_cancellation_targets_and_revision_fences() { + let (storage, _) = prepared_storage(); + let artifact = block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap(); + let stale = CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactSigning(ids().1), + NonZeroU64::new(9).unwrap(), + 11, + ) + .unwrap(); + assert_eq!( + block_on(storage.execute_authored(AuthoredAtomicCommand::Cancel(stale))), + Err(Error::InvalidAuthoredTransition) + ); + let cancel = CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactSigning(ids().1), + artifact.revision(), + 11, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::Cancel(cancel))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .signing_state(), + SigningState::Cancelled + ); + + let storage = signed_storage(); + let artifact = block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap(); + let cancel = CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactAdmission(ids().1), + artifact.revision(), + 13, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::Cancel(cancel))).unwrap(); + assert_eq!( + block_on(storage.authored_artifact(ids().1)) + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Cancelled + ); + + let storage = signed_storage(); + let plan = block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap(); + let stale = CancelAuthoredWork::new( + CancelAuthoredTarget::DeliveryPlan(ids().2), + NonZeroU64::MIN, + 13, + ) + .unwrap(); + assert_eq!( + block_on(storage.execute_authored(AuthoredAtomicCommand::Cancel(stale))), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + let cancel = CancelAuthoredWork::new( + CancelAuthoredTarget::DeliveryPlan(ids().2), + plan.revision(), + 13, + ) + .unwrap(); + block_on(storage.execute_authored(AuthoredAtomicCommand::Cancel(cancel))).unwrap(); + assert_eq!( + block_on(storage.authored_delivery_plan(ids().2)) + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::Cancelled + ); +} diff --git a/crates/storage/tests/authored_delivery.rs b/crates/storage/tests/authored_delivery.rs @@ -6,8 +6,9 @@ use radroots_storage::{ AuthoredArtifactId, FailureClass, RetrySchedule, WorkClaim, WorkFailure, WorkPhase, }, authored_delivery::{ - AuthoredDeliveryAttempt, AuthoredDeliveryPlan, AuthoredDeliveryPlanId, - AuthoredDeliveryState, DELIVERY_PLAN_ATTEMPTS_MAX, DeliveryAttemptOutcome, + AuthoredDeliveryAttempt, AuthoredDeliveryIntent, AuthoredDeliveryPlan, + AuthoredDeliveryPlanId, AuthoredDeliveryState, DELIVERY_PLAN_ATTEMPTS_MAX, + DeliveryAttemptOutcome, }, }; use radroots_transport::{ @@ -344,3 +345,577 @@ fn attempt_limit_is_checked_without_mutating_claimed_state() { ); assert_eq!(saturated, before); } + +#[test] +fn delivery_identity_intent_state_and_attempt_contracts_are_total() { + assert_eq!( + AuthoredDeliveryPlanId::new([0; 16]), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + let id = AuthoredDeliveryPlanId::new([7; 16]).unwrap(); + assert_eq!(id.as_bytes(), &[7; 16]); + assert_eq!(AuthoredDeliveryPlanId::try_from([7; 16]).unwrap(), id); + assert_eq!(<[u8; 16]>::from(id), [7; 16]); + + let targets = target_set(); + let policy = SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()); + assert_eq!( + AuthoredDeliveryIntent::new("", targets.clone(), policy.clone(), 100), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + assert_eq!( + AuthoredDeliveryIntent::new("intent", targets.clone(), policy.clone(), 0), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + let invalid_policy = SatisfactionPolicy::new( + SatisfactionClass::Accepted, + TargetPolicy::required(vec![ + Target::nostr_relay("wss://foreign.example") + .unwrap() + .fingerprint() + .clone(), + ]) + .unwrap(), + ); + assert_eq!( + AuthoredDeliveryIntent::new("intent", targets.clone(), invalid_policy, 100), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + for policy in [ + TargetPolicy::any(), + TargetPolicy::all(), + TargetPolicy::quorum(2).unwrap(), + TargetPolicy::required(vec![targets.targets()[0].fingerprint().clone()]).unwrap(), + ] { + for class in [SatisfactionClass::Accepted, SatisfactionClass::Delivered] { + let request = DeliveryRequest::new( + "intent", + DeliveryPayload::new(signed_event()), + targets.clone(), + SatisfactionPolicy::new(class, policy.clone()), + 100, + ) + .unwrap(); + let intent = AuthoredDeliveryIntent::from_request(&request); + assert_eq!(intent.request_id(), request.request_id()); + assert_eq!(intent.target_set(), request.target_set()); + assert_eq!(intent.satisfaction(), request.satisfaction()); + assert_eq!(intent.deadline_unix_ms(), 100); + assert_eq!( + intent.materialize(request.payload().clone()).unwrap(), + request + ); + AuthoredDeliveryPlan::new(id, artifact_id(9), intent, 10).unwrap(); + } + } + + for (state, terminal) in [ + (AuthoredDeliveryState::Pending, false), + (AuthoredDeliveryState::Retryable, false), + (AuthoredDeliveryState::Satisfied, true), + (AuthoredDeliveryState::Exhausted, true), + (AuthoredDeliveryState::FailedTerminal, true), + (AuthoredDeliveryState::Cancelled, true), + ] { + assert_eq!(state.is_terminal(), terminal); + } + + let outcome = DeliveryAttemptOutcome::Receipt(receipt( + &request(TargetPolicy::all()), + vec![DeliveryOutcome::accepted(), DeliveryOutcome::unavailable()], + )); + assert_eq!( + AuthoredDeliveryAttempt::reconstruct( + NonZeroU32::MIN, + 0, + outcome.clone(), + SatisfactionState::Pending, + ), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + let attempt = AuthoredDeliveryAttempt::reconstruct( + NonZeroU32::MIN, + 12, + outcome.clone(), + SatisfactionState::Pending, + ) + .unwrap(); + assert_eq!(attempt.attempt(), NonZeroU32::MIN); + assert_eq!(attempt.recorded_at_unix_ms(), 12); + assert_eq!(attempt.outcome(), &outcome); + assert_eq!(attempt.satisfaction(), SatisfactionState::Pending); +} + +fn artifact_id(value: u8) -> AuthoredArtifactId { + AuthoredArtifactId::new([value; 16]).unwrap() +} + +fn assert_plan_json_rejected( + mut value: serde_json::Value, + key: &str, + replacement: serde_json::Value, +) { + value[key] = replacement; + assert!( + serde_json::from_value::<AuthoredDeliveryPlan>(value).is_err(), + "forged field {key} was accepted" + ); +} + +#[test] +fn reconstructed_delivery_plans_fail_closed_for_each_durable_invariant() { + let base = plan(8, TargetPolicy::all()); + let value = serde_json::to_value(&base).unwrap(); + assert_plan_json_rejected(value.clone(), "created_at_unix_ms", serde_json::json!(0)); + assert_plan_json_rejected(value.clone(), "updated_at_unix_ms", serde_json::json!(9)); + assert_plan_json_rejected( + value.clone(), + "request_digest", + serde_json::to_value([0_u8; 32]).unwrap(), + ); + assert_plan_json_rejected( + value.clone(), + "attempt_count", + serde_json::json!(DELIVERY_PLAN_ATTEMPTS_MAX + 1), + ); + assert_plan_json_rejected(value.clone(), "attempt_count", serde_json::json!(1)); + assert_plan_json_rejected(value.clone(), "state", serde_json::json!("retryable")); + assert_plan_json_rejected(value.clone(), "state", serde_json::json!("failed_terminal")); + + let mut wrong_exhausted_phase = value.clone(); + wrong_exhausted_phase["state"] = serde_json::json!("exhausted"); + wrong_exhausted_phase["last_failure"] = + serde_json::to_value(retry_failure_for_phase(WorkPhase::Signing, 20)).unwrap(); + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(wrong_exhausted_phase).is_err()); + let mut wrong_exhausted_class = value.clone(); + wrong_exhausted_class["state"] = serde_json::json!("exhausted"); + wrong_exhausted_class["last_failure"] = + serde_json::to_value(retry_failure_for_phase(WorkPhase::Delivery, 20)).unwrap(); + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(wrong_exhausted_class).is_err()); + + let mut request_mismatch = value.clone(); + request_mismatch["request"]["request_id"] = serde_json::json!("different-request"); + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(request_mismatch).is_err()); + + let pending_receipt = receipt( + bound_request(&base), + vec![ + DeliveryOutcome::unavailable(), + DeliveryOutcome::unavailable(), + ], + ); + let attempt = AuthoredDeliveryAttempt::reconstruct( + NonZeroU32::MIN, + 12, + DeliveryAttemptOutcome::Receipt(pending_receipt), + SatisfactionState::Pending, + ) + .unwrap(); + let mut attempt_without_request = value.clone(); + attempt_without_request["request"] = serde_json::Value::Null; + attempt_without_request["attempts"] = serde_json::json!([attempt]); + attempt_without_request["attempt_count"] = serde_json::json!(1); + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(attempt_without_request).is_err()); + + let active = claim(&base, 8, 11); + let mut claimed = base.clone(); + claimed.claim(active, 11).unwrap(); + let claimed_value = serde_json::to_value(&claimed).unwrap(); + assert_plan_json_rejected( + claimed_value.clone(), + "state", + serde_json::json!("satisfied"), + ); + for (field, replacement) in [ + ("token", serde_json::to_value([0_u8; 16]).unwrap()), + ("acquired_at_unix_ms", serde_json::json!(10)), + ("row_revision", serde_json::json!(9)), + ] { + let mut forged = claimed_value.clone(); + forged["claim"][field] = replacement; + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(forged).is_err()); + } + + let mut retryable = plan(9, TargetPolicy::all()); + let active = claim(&retryable, 9, 11); + retryable.claim(active.clone(), 11).unwrap(); + retryable + .apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + receipt( + bound_request(&retryable), + vec![DeliveryOutcome::accepted(), DeliveryOutcome::unavailable()], + ), + Some(retry("delivery_pending", 1, 20)), + 12, + ) + .unwrap(); + let retryable_value = serde_json::to_value(&retryable).unwrap(); + for (key, replacement) in [ + ("attempt_count", serde_json::json!(0)), + ("retry", serde_json::Value::Null), + ("last_failure", serde_json::Value::Null), + ] { + assert_plan_json_rejected(retryable_value.clone(), key, replacement); + } + for (field, replacement) in [ + ("attempt", serde_json::json!(2)), + ("recorded_at_unix_ms", serde_json::json!(9)), + ("satisfaction", serde_json::json!("satisfied")), + ] { + let mut forged = retryable_value.clone(); + forged["attempts"][0][field] = replacement; + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(forged).is_err()); + } + for (field, replacement) in [ + ("attempt", serde_json::json!(2)), + ("not_before_unix_ms", serde_json::json!(12)), + ] { + let mut forged = retryable_value.clone(); + forged["retry"][field] = replacement; + assert!(serde_json::from_value::<AuthoredDeliveryPlan>(forged).is_err()); + } +} + +#[test] +fn delivery_claim_binding_cancellation_and_retry_validation_are_transactional() { + let mut unbound = AuthoredDeliveryPlan::new( + AuthoredDeliveryPlanId::new([10; 16]).unwrap(), + artifact_id(10), + AuthoredDeliveryIntent::from_request(&request(TargetPolicy::all())), + 10, + ) + .unwrap(); + assert!(unbound.request().is_none()); + assert_eq!(unbound.attempts(), &[]); + assert_eq!(unbound.attempt_count(), 0); + assert_eq!(unbound.retry(), None); + assert_eq!(unbound.claim_evidence(), None); + assert_eq!(unbound.last_failure(), None); + assert_eq!(unbound.created_at_unix_ms(), 10); + assert_eq!(unbound.updated_at_unix_ms(), 10); + assert_eq!(unbound.revision(), NonZeroU64::MIN); + let impossible_claim = WorkClaim::new( + [1; 16], + "worker", + NonZeroU64::MIN, + 11, + 20, + unbound.revision(), + ) + .unwrap(); + assert_eq!( + unbound.claim(impossible_claim, 11), + Err(Error::DeliveryPlanClaimConflict) + ); + assert_eq!( + unbound.evaluate_next_attempt(&DeliveryAttemptOutcome::Receipt(receipt( + &request(TargetPolicy::all()), + vec![DeliveryOutcome::accepted(), DeliveryOutcome::accepted()], + ))), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + assert_eq!( + unbound.bind_signed_event(signed_event(), 9), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + unbound.bind_signed_event(signed_event(), 11).unwrap(); + assert!(unbound.request().is_some()); + assert_eq!( + unbound.bind_signed_event(signed_event(), 12), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + let active = claim(&unbound, 10, 12); + let stale_revision = WorkClaim::new( + [11; 16], + "worker", + NonZeroU64::new(11).unwrap(), + 12, + 20, + NonZeroU64::MIN, + ) + .unwrap(); + assert_eq!( + unbound.claim(stale_revision, 12), + Err(Error::DeliveryPlanClaimConflict) + ); + unbound.claim(active.clone(), 12).unwrap(); + assert_eq!( + unbound.claim(claim(&unbound, 11, 13), 13), + Err(Error::DeliveryPlanClaimConflict) + ); + let before = unbound.clone(); + assert_eq!( + unbound.apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + receipt( + bound_request(&unbound), + vec![ + DeliveryOutcome::unavailable(), + DeliveryOutcome::unavailable() + ], + ), + Some( + RetrySchedule::new( + NonZeroU32::MIN, + 20, + retry_failure_for_phase(WorkPhase::Signing, 20), + ) + .unwrap() + ), + 13, + ), + Err(Error::InvalidRetrySchedule) + ); + assert_eq!(unbound, before); + + let mut cancelled = plan(11, TargetPolicy::all()); + cancelled.cancel(11).unwrap(); + assert_eq!(cancelled.state(), AuthoredDeliveryState::Cancelled); + assert_eq!( + cancelled.cancel(12), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + assert_eq!( + cancelled.claim(claim(&cancelled, 12, 12), 12), + Err(Error::DeliveryPlanClaimConflict) + ); + let mut backwards = plan(12, TargetPolicy::all()); + let before = backwards.clone(); + assert_eq!(backwards.cancel(9), Err(Error::InvalidAuthoredDeliveryPlan)); + assert_eq!(backwards, before); +} + +fn retry_failure_for_phase(phase: WorkPhase, at: u64) -> WorkFailure { + WorkFailure::new( + "delivery_pending", + phase, + FailureClass::Retryable, + Some(at), + None, + ) + .unwrap() +} + +fn claimed_plan(value: u8) -> (AuthoredDeliveryPlan, WorkClaim) { + let mut plan = plan(value, TargetPolicy::all()); + let active = claim(&plan, value, 11); + plan.claim(active.clone(), 11).unwrap(); + (plan, active) +} + +#[test] +fn delivery_attempt_retry_and_terminal_error_matrix_is_fail_closed() { + let (mut satisfied, active) = claimed_plan(20); + let accepted = receipt( + bound_request(&satisfied), + vec![DeliveryOutcome::accepted(), DeliveryOutcome::accepted()], + ); + assert_eq!( + satisfied.apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + accepted, + Some(retry("unexpected_retry", 1, 20)), + 12, + ), + Err(Error::InvalidRetrySchedule) + ); + + let (mut exhausted, active) = claimed_plan(21); + let rejected = receipt( + bound_request(&exhausted), + vec![DeliveryOutcome::rejected(), DeliveryOutcome::rejected()], + ); + assert_eq!( + exhausted.apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + rejected, + Some(retry("unexpected_retry", 1, 20)), + 12, + ), + Err(Error::InvalidRetrySchedule) + ); + + let (mut validation_rollback, active) = claimed_plan(22); + let pending = receipt( + bound_request(&validation_rollback), + vec![DeliveryOutcome::accepted(), DeliveryOutcome::unavailable()], + ); + let before = validation_rollback.clone(); + assert_eq!( + validation_rollback.apply_receipt( + active.token(), + active.generation(), + active.row_revision(), + pending, + Some(retry("delivery_pending", 2, 12)), + 12, + ), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + assert_eq!(validation_rollback, before); + + for (value, evidence, retryability, retry_schedule, expected) in [ + ( + 23, + vec![DeliveryOutcome::accepted(), DeliveryOutcome::accepted()], + Retryability::Terminal, + Some(retry("sink_failure", 1, 20)), + Err(Error::InvalidRetrySchedule), + ), + ( + 24, + vec![DeliveryOutcome::rejected(), DeliveryOutcome::rejected()], + Retryability::Terminal, + Some(retry("sink_failure", 1, 20)), + Err(Error::InvalidRetrySchedule), + ), + ( + 25, + Vec::new(), + Retryability::Retryable, + Some(retry("different_failure", 1, 20)), + Err(Error::InvalidRetrySchedule), + ), + ( + 26, + Vec::new(), + Retryability::Terminal, + Some(retry("sink_failure", 1, 20)), + Err(Error::InvalidRetrySchedule), + ), + ] { + let (mut plan, active) = claimed_plan(value); + let request = bound_request(&plan); + let partial = request + .target_set() + .targets() + .iter() + .cloned() + .zip(evidence) + .map(|(target, outcome)| DeliveryTargetReceipt::attempted(target, outcome)) + .collect(); + let failure = SinkFailure::for_request( + request, + "sink_failure", + retryability, + (retryability == Retryability::Retryable).then_some(20), + None, + partial, + ) + .unwrap(); + let before = plan.clone(); + assert_eq!( + plan.apply_sink_failure( + active.token(), + active.generation(), + active.row_revision(), + failure, + retry_schedule, + 12, + ), + expected + ); + assert_eq!(plan, before); + } + + let (mut exhausted, active) = claimed_plan(27); + let request = bound_request(&exhausted); + let partial = request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| DeliveryTargetReceipt::attempted(target, DeliveryOutcome::rejected())) + .collect(); + let failure = SinkFailure::for_request( + request, + "sink_failure", + Retryability::Terminal, + None, + None, + partial, + ) + .unwrap(); + exhausted + .apply_sink_failure( + active.token(), + active.generation(), + active.row_revision(), + failure, + None, + 12, + ) + .unwrap(); + assert_eq!(exhausted.state(), AuthoredDeliveryState::Exhausted); +} + +#[test] +fn delivery_claim_guards_distinguish_generation_time_revision_and_clock() { + let mut delivery = plan(28, TargetPolicy::all()); + let first = WorkClaim::new( + [1; 16], + "worker", + NonZeroU64::new(2).unwrap(), + 11, + 20, + delivery.revision(), + ) + .unwrap(); + delivery.claim(first, 11).unwrap(); + let lower_generation = WorkClaim::new( + [2; 16], + "worker", + NonZeroU64::MIN, + 20, + 30, + delivery.revision(), + ) + .unwrap(); + assert_eq!( + delivery.claim(lower_generation, 20), + Err(Error::DeliveryPlanClaimConflict) + ); + + let mut wrong_time = plan(29, TargetPolicy::all()); + let acquired_later = WorkClaim::new( + [3; 16], + "worker", + NonZeroU64::MIN, + 12, + 20, + wrong_time.revision(), + ) + .unwrap(); + assert_eq!( + wrong_time.claim(acquired_later, 11), + Err(Error::DeliveryPlanClaimConflict) + ); + + let mut backwards = plan(30, TargetPolicy::all()); + let claim = WorkClaim::new( + [4; 16], + "worker", + NonZeroU64::MIN, + 9, + 20, + backwards.revision(), + ) + .unwrap(); + let before = backwards.clone(); + assert_eq!( + backwards.claim(claim, 9), + Err(Error::InvalidAuthoredDeliveryPlan) + ); + assert_eq!(backwards, before); +} diff --git a/crates/storage/tests/conformance/suite.rs b/crates/storage/tests/conformance/suite.rs @@ -2,10 +2,10 @@ use futures_executor::block_on; use radroots_event::{SignedEvent, wire::Nip01EventWire}; use radroots_protocol::runtime::v1::OperationId; use radroots_storage::{ - Error, EventStore, Journal, Outbox, ProjectionStore, + EventStore, Journal, Outbox, ProjectionStore, atomic::{ AtomicCommit, AtomicCommitDigest, AtomicCommitDisposition, AtomicCommitId, AtomicStorage, - AtomicWorkflow, CommitEnqueued, CommitIngested, CommitSigned, + AtomicWorkflow, CommitIngested, }, backup::{ BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupPlan, @@ -13,14 +13,8 @@ use radroots_storage::{ RestoreMemberStatus, RestorePlan, RestoreStage, RestoreTransition, StorageReliability, }, event::{EventAdmission, EventQuery, EventQueryBounds}, - journal::{ - IdempotencyDigest, IdempotencyKey, JournalRevision, JournalStage, OperationInstanceId, - PrepareOperation, - }, - outbox::{ - ClaimOutboxItems, DeliveryAttempt, DeliveryAttemptEvidence, DeliveryPlanDigest, - EnqueueDisposition, EnqueueOutboxItem, LeaseId, LeaseOwner, OutboxItemId, OutboxStage, - }, + journal::{IdempotencyDigest, IdempotencyKey, OperationInstanceId, PrepareOperation}, + outbox::{DeliveryPlanDigest, EnqueueDisposition, EnqueueOutboxItem, OutboxItemId}, private_artifact::{ ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DurableSecretReference, PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStore, RetentionPolicy, @@ -29,10 +23,9 @@ use radroots_storage::{ status::{ShutdownState, StorageBackend}, }; use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Target, TargetSet, TransportId, - outcome::DeliveryOutcome, + DeliveryRequest, Target, TargetSet, TransportId, policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - sink::{DeliveryPayload, DeliveryTargetReceipt}, + sink::DeliveryPayload, source::{EventProvenance, ObservedEvent}, }; @@ -187,164 +180,38 @@ pub(crate) fn assert_conflict_conformance(harness: &impl StorageConformanceHarne } pub(crate) fn assert_atomic_workflow_conformance(harness: &impl StorageConformanceHarness) { - let instance = OperationInstanceId::new([10; 16]).expect("operation instance"); - let prepared_request = atomic_commit( - 10, - 10, - 100, - AtomicWorkflow::Prepared(prepare(instance, "atomic", 10)), - ); - let prepared = block_on(harness.atomic_storage().commit(prepared_request.clone())) - .expect("atomic prepare"); - assert_eq!(prepared.disposition(), AtomicCommitDisposition::Committed); - assert_eq!( - block_on(harness.atomic_storage().commit(prepared_request)) - .expect("prepare replay") - .disposition(), - AtomicCommitDisposition::Replay - ); - - let event = signed_event("conformance-atomic"); - let signed = block_on(harness.atomic_storage().commit(atomic_commit( - 11, - 11, - 110, - AtomicWorkflow::Signed(Box::new(CommitSigned::new( - instance, - JournalRevision::INITIAL, - event.clone(), - ))), - ))) - .expect("atomic sign"); - assert_eq!( - signed.outcome().kind(), - radroots_storage::atomic::AtomicWorkflowKind::Signed - ); - - let enqueue = enqueue([11; 16], instance, event.clone()); - assert_eq!( - CommitEnqueued::new( - instance, - JournalRevision::new(2).expect("journal revision"), - admission(event.clone(), 120), - enqueue.clone(), - 0, - ), - Err(Error::AtomicWorkflowMismatch) - ); - let enqueued = block_on( - harness.atomic_storage().commit(atomic_commit( - 12, - 12, - 120, - AtomicWorkflow::Enqueued(Box::new( - CommitEnqueued::new( - instance, - JournalRevision::new(2).expect("journal revision"), - admission(event, 120), - enqueue, - 120, + let event = signed_event("conformance-ingest"); + let request = atomic_commit( + 14, + 14, + 150, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new( + admission(event, 150), + Some( + ProjectionCheckpoint::new( + ProjectionId::parse("conformance.ingest").expect("projection id"), + ProjectionGeneration::new([14; 32]).expect("projection generation"), + None, + 1, + 150, ) - .expect("enqueue workflow"), - )), - )), - ) - .expect("atomic enqueue"); - assert_eq!( - block_on(harness.journal().operation(instance)) - .expect("journal lookup") - .expect("journal record") - .state() - .stage(), - JournalStage::Committed - ); - assert_eq!( - enqueued.outcome().kind(), - radroots_storage::atomic::AtomicWorkflowKind::Enqueued - ); - - let claimed = block_on( - harness.outbox().claim( - ClaimOutboxItems::new( - LeaseOwner::parse("conformance-worker").expect("lease owner"), - LeaseId::new([12; 16]).expect("lease id"), - 130, - 150, - 1, - ) - .expect("claim request"), - ), - ) - .expect("claim outbox") - .pop() - .expect("claimed item"); - let receipt = DeliveryReceipt::for_request( - claimed.record().request(), - claimed - .record() - .request() - .target_set() - .targets() - .iter() - .cloned() - .map(|target| DeliveryTargetReceipt::attempted(target, DeliveryOutcome::delivered())) - .collect(), - ) - .expect("delivery receipt"); - let evidence = DeliveryAttemptEvidence::new( - claimed.record().item_id(), - claimed.lease().id(), - claimed.record().revision(), - DeliveryAttempt::FIRST, - receipt, - 140, - ) - .expect("delivery evidence"); - let delivered = block_on(harness.atomic_storage().commit(atomic_commit( - 13, - 13, - 140, - AtomicWorkflow::Delivered(Box::new(evidence)), - ))) - .expect("atomic deliver"); - assert_eq!( - delivered.outcome().kind(), - radroots_storage::atomic::AtomicWorkflowKind::Delivered + .expect("projection checkpoint"), + ), + ))), ); - assert_eq!( - block_on(harness.outbox().item(claimed.record().item_id())) - .expect("outbox lookup") - .expect("outbox item") - .stage(), - OutboxStage::Satisfied - ); - - let ingest_event = signed_event("conformance-ingest"); - let ingested = block_on( - harness.atomic_storage().commit(atomic_commit( - 14, - 14, - 150, - AtomicWorkflow::Ingested(Box::new(CommitIngested::new( - admission(ingest_event, 150), - Some( - ProjectionCheckpoint::new( - ProjectionId::parse("conformance.ingest").expect("projection id"), - ProjectionGeneration::new([14; 32]).expect("projection generation"), - None, - 1, - 150, - ) - .expect("projection checkpoint"), - ), - ))), - )), - ) - .expect("atomic ingest"); + let ingested = + block_on(harness.atomic_storage().commit(request.clone())).expect("atomic ingest"); + assert_eq!(ingested.disposition(), AtomicCommitDisposition::Committed); assert_eq!( ingested.outcome().kind(), radroots_storage::atomic::AtomicWorkflowKind::Ingested ); + assert_eq!( + block_on(harness.atomic_storage().commit(request)) + .expect("atomic replay") + .disposition(), + AtomicCommitDisposition::Replay + ); } pub(crate) fn assert_reliability_and_close_conformance(harness: &impl StorageConformanceHarness) { diff --git a/crates/storage/tests/journal.rs b/crates/storage/tests/journal.rs @@ -530,4 +530,63 @@ fn journal_value_and_state_validation_matrix_is_complete() { JournalTransition::resume(instance_id, resumed.revision()).instance_id(), instance_id ); + + assert_eq!( + PrepareOperation::new( + instance(9), + OperationId::SyncPush, + key(9), + IdempotencyDigest::new([9; 32]), + 0, + ), + Err(Error::InvalidOperationTimestamp) + ); + + let prepared = prepare(instance(10), 10, 100).into_record().unwrap(); + let signed = prepared + .transition(&JournalTransition::signed( + instance(10), + prepared.revision(), + event_id("c"), + )) + .unwrap(); + assert_eq!( + signed.transition(&JournalTransition::committed( + instance(10), + signed.revision(), + event_id("c"), + 99, + )), + Err(Error::InvalidJournalTransition) + ); + let cancelled_signed = signed + .transition(&JournalTransition::cancelled( + instance(10), + signed.revision(), + 100, + )) + .unwrap(); + assert_eq!( + cancelled_signed.cancellation(), + CancellationState::CancelledBeforeCommit + ); + + let cancellation_recovery = RecoveryRecord::new( + RecoveryPoint::Prepared, + RecoveryReason::CancelledBeforeCommit, + 1, + None, + ) + .unwrap(); + let recovered = prepared + .transition(&JournalTransition::recoverable( + instance(10), + prepared.revision(), + cancellation_recovery, + )) + .unwrap(); + assert_eq!( + recovered.cancellation(), + CancellationState::CancelledBeforeCommit + ); } diff --git a/crates/storage/tests/memory.rs b/crates/storage/tests/memory.rs @@ -7,15 +7,15 @@ use radroots_storage::{ Error, EventStore, Journal, Outbox, ProjectionStore, atomic::{ AtomicCommit, AtomicCommitDigest, AtomicCommitDisposition, AtomicCommitId, AtomicStorage, - AtomicWorkflow, CommitIngested, CommitSigned, + AtomicWorkflow, CommitIngested, }, event::{ EventAdmission, EventPosition, EventQuery, EventQueryBounds, EventSequence, SourceGeneration, }, journal::{ - IdempotencyDigest, IdempotencyKey, JournalRevision, JournalStage, OperationInstanceId, - PrepareOperation, + IdempotencyDigest, IdempotencyKey, JournalStage, OperationInstanceId, PrepareOperation, + RECOVERABLE_QUERY_LIMIT_MAX, }, memory::MemoryStorage, outbox::{ @@ -24,8 +24,8 @@ use radroots_storage::{ }, private_artifact::{ ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DurableSecretReference, - PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactRevision, PrivateArtifactStore, - RetentionPolicy, + EXPIRED_ARTIFACT_QUERY_LIMIT_MAX, PrivateArtifactId, PrivateArtifactMetadata, + PrivateArtifactRevision, PrivateArtifactStore, RetentionPolicy, }, projection::{ InvalidationReason, ProjectionCheckpoint, ProjectionGeneration, ProjectionHealth, @@ -164,45 +164,22 @@ fn memory_event_and_journal_implement_the_canonical_spis() { } #[test] -fn memory_atomic_commits_share_event_and_journal_state_and_replay() { +fn memory_atomic_ingest_commits_event_state_and_replays() { let store = MemoryStorage::default(); - let instance = OperationInstanceId::new([2; 16]).expect("instance"); - let prepared_request = atomic(1, 1, AtomicWorkflow::Prepared(prepare(instance))); - let prepared = block_on(store.commit(prepared_request.clone())).expect("atomic prepare"); - assert_eq!(prepared.disposition(), AtomicCommitDisposition::Committed); + let event = signed_event(); + let ingest = atomic( + 1, + 1, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new(admission(event, 20), None))), + ); + let committed = block_on(store.commit(ingest.clone())).expect("atomic ingest"); + assert_eq!(committed.disposition(), AtomicCommitDisposition::Committed); assert_eq!( - block_on(store.commit(prepared_request)) + block_on(store.commit(ingest)) .expect("atomic replay") .disposition(), AtomicCommitDisposition::Replay ); - - let event = signed_event(); - let signed_request = atomic( - 2, - 2, - AtomicWorkflow::Signed(Box::new(CommitSigned::new( - instance, - JournalRevision::INITIAL, - event.clone(), - ))), - ); - block_on(store.commit(signed_request)).expect("atomic signed"); - assert_eq!( - block_on(store.operation(instance)) - .expect("operation") - .expect("journal record") - .state() - .stage(), - JournalStage::Signed - ); - - let ingest = atomic( - 3, - 3, - AtomicWorkflow::Ingested(Box::new(CommitIngested::new(admission(event, 20), None))), - ); - block_on(store.commit(ingest)).expect("atomic ingest"); assert_eq!( block_on(EventStore::status(&store)) .expect("status") @@ -580,14 +557,40 @@ fn memory_projection_and_private_artifact_conflict_matrix_is_complete() { ) .unwrap(); block_on(store.invalidate(invalidation.clone())).unwrap(); + assert_eq!( + block_on(store.invalidate(invalidation.clone())) + .unwrap() + .health(), + ProjectionHealth::Invalidated + ); + let conflicting_invalidation = ProjectionInvalidation::new( + projection_id.clone(), + initial, + ProjectionGeneration::new([6; 32]).unwrap(), + InvalidationReason::ProjectionGenerationChanged, + 111, + ) + .unwrap(); + assert_eq!( + block_on(store.invalidate(conflicting_invalidation)), + Err(Error::ProjectionRevisionConflict) + ); assert!( block_on(store.invalidation(projection_id.clone(), replacement)) .unwrap() .is_some() ); + assert!( + block_on(store.invalidation( + projection_id.clone(), + ProjectionGeneration::new([9; 32]).unwrap(), + )) + .unwrap() + .is_none() + ); let ticket = RebuildTicket::requested( RebuildTicketId::new([7; 16]).unwrap(), - invalidation, + invalidation.clone(), store.generation(), None, RawSourceDigest::new([8; 32]), @@ -597,6 +600,18 @@ fn memory_projection_and_private_artifact_conflict_matrix_is_complete() { block_on(store.request_rebuild(ticket.clone())).unwrap(), ticket ); + let conflicting_ticket = RebuildTicket::requested( + ticket.ticket_id(), + invalidation, + store.generation(), + None, + RawSourceDigest::new([9; 32]), + ) + .unwrap(); + assert_eq!( + block_on(store.request_rebuild(conflicting_ticket)), + Err(Error::ProjectionRevisionConflict) + ); assert_eq!( block_on(store.request_rebuild(ticket.clone())).unwrap(), ticket @@ -645,3 +660,162 @@ fn memory_projection_and_private_artifact_conflict_matrix_is_complete() { Err(Error::InvalidExpiredArtifactQueryLimit) ); } + +#[test] +fn memory_branch_guards_distinguish_each_identity_and_bound() { + let store = MemoryStorage::new(SourceGeneration::new([7; 32]).unwrap()); + let event = signed_event(); + block_on(store.admit(admission(event.clone(), 10))).unwrap(); + let other_id = radroots_event::EventId::parse("f".repeat(64)).unwrap(); + let selected = block_on(store.query_raw( + EventQuery::for_ids(EventQueryBounds::first(10).unwrap(), vec![other_id]).unwrap(), + )) + .unwrap(); + assert!(selected.items().is_empty()); + + let instance = OperationInstanceId::new([1; 16]).unwrap(); + block_on(store.prepare(prepare(instance))).unwrap(); + let different_operation = PrepareOperation::new( + OperationInstanceId::new([2; 16]).unwrap(), + OperationId::SyncPull, + IdempotencyKey::parse("memory-operation").unwrap(), + IdempotencyDigest::new([3; 32]), + 100, + ) + .unwrap(); + assert_eq!( + block_on(store.prepare(different_operation)), + Err(Error::IdempotencyConflict) + ); + let different_instance = PrepareOperation::new( + OperationInstanceId::new([2; 16]).unwrap(), + OperationId::SyncPush, + IdempotencyKey::parse("memory-operation").unwrap(), + IdempotencyDigest::new([3; 32]), + 100, + ) + .unwrap(); + assert_eq!( + block_on(store.prepare(different_instance)), + Err(Error::IdempotencyConflict) + ); + assert_eq!( + block_on(store.recoverable(RECOVERABLE_QUERY_LIMIT_MAX + 1)), + Err(Error::InvalidJournalQueryLimit) + ); + + let private = MemoryStorage::default(); + block_on(private.put_metadata(private_metadata())).unwrap(); + assert_eq!( + block_on(private.expired(1, EXPIRED_ARTIFACT_QUERY_LIMIT_MAX + 1)), + Err(Error::InvalidExpiredArtifactQueryLimit) + ); + block_on(private.mark_expired( + PrivateArtifactId::new([9; 16]).unwrap(), + PrivateArtifactRevision::INITIAL, + 300, + )) + .unwrap(); + assert!(block_on(private.expired(400, 1)).unwrap().is_empty()); +} + +#[test] +fn memory_outbox_replay_checks_each_durable_plan_field_and_retry_window() { + let make_request = |id: &str| { + DeliveryRequest::new( + id, + DeliveryPayload::new(signed_event()), + TargetSet::new(vec![Target::nostr_relay("wss://relay.example").unwrap()]).unwrap(), + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + 1_000, + ) + .unwrap() + }; + let make_item = |instance: u8, digest: u8, request: DeliveryRequest, created: u64| { + EnqueueOutboxItem::new( + OutboxItemId::new([1; 16]).unwrap(), + OperationInstanceId::new([instance; 16]).unwrap(), + DeliveryPlanDigest::new([digest; 32]), + request, + created, + ) + .unwrap() + }; + + for conflict in [ + make_item(2, 1, make_request("memory-delivery"), 100), + make_item(1, 2, make_request("memory-delivery"), 100), + make_item(1, 1, make_request("different-delivery"), 100), + make_item(1, 1, make_request("memory-delivery"), 101), + ] { + let store = MemoryStorage::default(); + block_on(store.enqueue(make_item(1, 1, make_request("memory-delivery"), 100))).unwrap(); + assert_eq!( + block_on(store.enqueue(conflict)), + Err(Error::OutboxPlanConflict) + ); + } + + let store = MemoryStorage::default(); + block_on(store.enqueue(make_item(1, 1, make_request("memory-delivery"), 100))).unwrap(); + let lease_seed = LeaseId::new([3; 16]).unwrap(); + let claimed = block_on( + store.claim( + ClaimOutboxItems::new( + LeaseOwner::parse("worker").unwrap(), + lease_seed, + 200, + 250, + 1, + ) + .unwrap(), + ), + ) + .unwrap(); + let record = &claimed[0]; + block_on(store.release( + record.record().item_id(), + record.lease().id(), + record.record().revision(), + 210, + Some(300), + )) + .unwrap(); + let early = ClaimOutboxItems::new( + LeaseOwner::parse("worker").unwrap(), + LeaseId::new([4; 16]).unwrap(), + 299, + 350, + 1, + ) + .unwrap(); + assert!(block_on(store.claim(early)).unwrap().is_empty()); +} + +#[test] +fn memory_atomic_replay_rejects_a_changed_digest_without_mutation() { + let store = MemoryStorage::default(); + let event = signed_event(); + let committed = atomic( + 1, + 1, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new( + admission(event.clone(), 20), + None, + ))), + ); + block_on(store.commit(committed)).unwrap(); + let conflict = atomic( + 1, + 2, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new(admission(event, 20), None))), + ); + assert_eq!( + block_on(store.commit(conflict)), + Err(Error::AtomicCommitConflict) + ); + assert_eq!( + block_on(EventStore::status(&store)).unwrap().raw_events(), + 1 + ); +} diff --git a/crates/storage_sqlite/src/atomic.rs b/crates/storage_sqlite/src/atomic.rs @@ -1,4 +1,4 @@ -use crate::{SqliteStorage, journal, outbox, projection}; +use crate::{SqliteStorage, projection}; use radroots_storage::{ Error, atomic::{ @@ -10,7 +10,6 @@ use radroots_storage::{ AdmissionDisposition, AdmissionReceipt, AdmissionStage, BoxFuture, EventPosition, EventSequence, SourceGeneration, }, - journal::JournalTransition, }; use sqlx::{Row, Sqlite}; @@ -137,52 +136,6 @@ async fn execute_workflow( workflow: AtomicWorkflow, ) -> Result<AtomicCommitOutcome, Error> { match workflow { - AtomicWorkflow::Prepared(operation) => Ok(AtomicCommitOutcome::Prepared { - journal: journal::prepare_transaction(transaction, operation) - .await? - .record() - .clone(), - }), - AtomicWorkflow::Signed(signed) => { - let event_id = *signed.event().id(); - let journal = journal::transition_transaction( - transaction, - JournalTransition::signed( - signed.instance_id(), - signed.expected_revision(), - event_id, - ), - ) - .await?; - Ok(AtomicCommitOutcome::Signed { journal, event_id }) - } - AtomicWorkflow::Enqueued(enqueued) => { - let admission = storage - .admit_transaction(transaction, enqueued.admission().clone()) - .await?; - let outbox = outbox::enqueue_transaction(transaction, enqueued.outbox().clone()) - .await? - .record() - .clone(); - let journal = journal::transition_transaction( - transaction, - JournalTransition::committed( - enqueued.instance_id(), - enqueued.expected_revision(), - *enqueued.admission().event_id(), - enqueued.committed_at_unix_ms(), - ), - ) - .await?; - Ok(AtomicCommitOutcome::Enqueued { - journal, - admission, - outbox: Box::new(outbox), - }) - } - AtomicWorkflow::Delivered(evidence) => Ok(AtomicCommitOutcome::Delivered { - outbox: Box::new(outbox::record_attempt_transaction(transaction, *evidence).await?), - }), AtomicWorkflow::Ingested(ingested) => { let admission = storage .admit_transaction(transaction, ingested.admission().clone()) @@ -238,44 +191,6 @@ fn encode_outcome(outcome: &AtomicCommitOutcome) -> Result<Vec<u8>, Error> { let mut bytes = Vec::with_capacity(512); bytes.push(RECEIPT_FORMAT_VERSION); match outcome { - AtomicCommitOutcome::Prepared { journal } => { - bytes.push(0); - put_blob( - &mut bytes, - journal::encode_record_snapshot(journal)?.as_slice(), - )?; - } - AtomicCommitOutcome::Signed { journal, event_id } => { - bytes.push(1); - put_blob( - &mut bytes, - journal::encode_record_snapshot(journal)?.as_slice(), - )?; - bytes.extend_from_slice(event_id.as_bytes()); - } - AtomicCommitOutcome::Enqueued { - journal, - admission, - outbox, - } => { - bytes.push(2); - put_blob( - &mut bytes, - journal::encode_record_snapshot(journal)?.as_slice(), - )?; - encode_admission(&mut bytes, admission); - put_blob( - &mut bytes, - outbox::encode_record_snapshot(outbox)?.as_slice(), - )?; - } - AtomicCommitOutcome::Delivered { outbox } => { - bytes.push(3); - put_blob( - &mut bytes, - outbox::encode_record_snapshot(outbox)?.as_slice(), - )?; - } AtomicCommitOutcome::Ingested { admission, projection, @@ -309,30 +224,6 @@ fn decode_outcome(bytes: &[u8]) -> Result<AtomicCommitOutcome, Error> { return Err(Error::AtomicCommitFailed); } let outcome = match cursor.byte()? { - 0 => AtomicCommitOutcome::Prepared { - journal: journal::decode_record_snapshot(cursor.blob()?) - .map_err(|_| Error::AtomicCommitFailed)?, - }, - 1 => AtomicCommitOutcome::Signed { - journal: journal::decode_record_snapshot(cursor.blob()?) - .map_err(|_| Error::AtomicCommitFailed)?, - event_id: radroots_storage::event::EventId::from_bytes(cursor.array()?), - }, - 2 => AtomicCommitOutcome::Enqueued { - journal: journal::decode_record_snapshot(cursor.blob()?) - .map_err(|_| Error::AtomicCommitFailed)?, - admission: decode_admission(&mut cursor)?, - outbox: Box::new( - outbox::decode_record_snapshot(cursor.blob()?) - .map_err(|_| Error::AtomicCommitFailed)?, - ), - }, - 3 => AtomicCommitOutcome::Delivered { - outbox: Box::new( - outbox::decode_record_snapshot(cursor.blob()?) - .map_err(|_| Error::AtomicCommitFailed)?, - ), - }, 4 => { let admission = decode_admission(&mut cursor)?; let projection = match cursor.byte()? { @@ -469,20 +360,12 @@ fn preserve_primary<T>(primary: Error, _rollback: Result<(), T>) -> Error { const fn workflow_name(kind: AtomicWorkflowKind) -> &'static str { match kind { - AtomicWorkflowKind::Prepared => "prepared", - AtomicWorkflowKind::Signed => "signed", - AtomicWorkflowKind::Enqueued => "enqueued", - AtomicWorkflowKind::Delivered => "delivered", AtomicWorkflowKind::Ingested => "ingested", } } fn workflow_kind(value: &str) -> Result<AtomicWorkflowKind, Error> { match value.as_bytes() { - b"prepared" => Ok(AtomicWorkflowKind::Prepared), - b"signed" => Ok(AtomicWorkflowKind::Signed), - b"enqueued" => Ok(AtomicWorkflowKind::Enqueued), - b"delivered" => Ok(AtomicWorkflowKind::Delivered), b"ingested" => Ok(AtomicWorkflowKind::Ingested), _ => Err(Error::AtomicCommitFailed), } @@ -515,19 +398,8 @@ mod tests { use crate::migration::runtime::{MIGRATIONS, migration_sql}; use radroots_event::{SignedEvent, wire::Nip01EventWire}; use radroots_storage::{ - Journal, Outbox, - atomic::{CommitEnqueued, CommitIngested, CommitSigned}, - event::EventAdmission, - journal::{ - IdempotencyDigest, IdempotencyKey, JournalRevision, JournalStage, JournalTransition, - OperationId, OperationInstanceId, PrepareOperation, - }, - outbox::{ - ClaimOutboxItems, DeliveryAttempt, DeliveryAttemptEvidence, DeliveryOutcome, - DeliveryPayload, DeliveryPlanDigest, DeliveryReceipt, DeliveryRequest, - DeliveryTargetReceipt, EnqueueOutboxItem, LeaseId, LeaseOwner, OutboxItemId, - SatisfactionClass, SatisfactionPolicy, TargetPolicy, TargetSet, - }, + atomic::{AtomicWorkflow, CommitIngested}, + event::{EventAdmission, SourceGeneration}, projection::{ProjectionCheckpoint, ProjectionGeneration, ProjectionId}, status::EventStoreMode, }; @@ -566,36 +438,18 @@ mod tests { SqliteStorage::new(pool, generation, mode) } - fn instance(byte: u8) -> OperationInstanceId { - OperationInstanceId::new([byte; 16]).expect("instance") - } - - fn prepare(byte: u8, at: u64) -> PrepareOperation { - PrepareOperation::new( - instance(byte), - OperationId::SyncPush, - IdempotencyKey::parse(format!("atomic-operation-{byte:02x}")).expect("idempotency key"), - IdempotencyDigest::new([byte; 32]), - at, - ) - .expect("prepare") - } - - fn signed_event(content: &str, created_at: u64) -> SignedEvent { + fn signed_event() -> SignedEvent { let mut wire = Nip01EventWire { id: "0".repeat(64), pubkey: "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df".to_owned(), - created_at, + created_at: 1_800_001_001, kind: 1, tags: vec![], - content: content.to_owned(), + content: "atomic inbound".to_owned(), sig: "42".repeat(64), extra: Default::default(), }; - wire.id = wire - .computed_event_id() - .expect("canonical event id") - .to_hex(); + wire.id = wire.computed_event_id().expect("event id").to_hex(); let raw_json = serde_json::json!({ "id": &wire.id, "pubkey": &wire.pubkey, @@ -609,7 +463,7 @@ mod tests { SignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") } - fn admission(event: SignedEvent, observed_at: u64) -> EventAdmission { + fn admission(observed_at: u64) -> EventAdmission { let target = Target::new(TransportId::NOSTR, "wss://atomic.example").expect("target"); let provenance = EventProvenance::new( TransportId::NOSTR, @@ -617,534 +471,108 @@ mod tests { observed_at, ) .expect("provenance"); - EventAdmission::raw(ObservedEvent::new(event, provenance)) + EventAdmission::raw(ObservedEvent::new(signed_event(), provenance)) } - fn delivery_request(event: SignedEvent) -> DeliveryRequest { - DeliveryRequest::new( - "atomic-delivery-request", - DeliveryPayload::new(event), - TargetSet::new(vec![ - Target::nostr_relay("wss://one.atomic.example").expect("first target"), - Target::nostr_relay("wss://two.atomic.example").expect("second target"), - ]) - .expect("target set"), - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), - 10_000, - ) - .expect("delivery request") - } - - fn atomic_commit(byte: u8, at: u64, workflow: AtomicWorkflow) -> AtomicCommit { + fn commit(byte: u8, digest: u8, workflow: AtomicWorkflow) -> AtomicCommit { AtomicCommit::new( AtomicCommitId::new([byte; 16]).expect("commit id"), - AtomicCommitDigest::new([byte; 32]), - at, + AtomicCommitDigest::new([digest; 32]), + 100, workflow, ) .expect("atomic commit") } - fn checkpoint(sequence: u64, at: u64) -> ProjectionCheckpoint { + fn checkpoint() -> ProjectionCheckpoint { ProjectionCheckpoint::new( ProjectionId::parse("atomic_projection").expect("projection id"), ProjectionGeneration::new([81; 32]).expect("projection generation"), - Some(EventPosition::new( - SourceGeneration::new([71; 32]).expect("source generation"), - EventSequence::new(sequence).expect("sequence"), - )), - sequence, - at, + None, + 1, + 100, ) .expect("checkpoint") } #[tokio::test] - async fn workflows_commit_replay_and_reconstruct_original_receipts() { + async fn inbound_commit_is_atomic_replayable_and_reconstructable() { let store = store(EventStoreMode::ReadWrite).await; - let prepared_request = atomic_commit(1, 100, AtomicWorkflow::Prepared(prepare(1, 100))); - let prepared = store - .commit(prepared_request.clone()) - .await - .expect("prepare commit"); - assert_eq!(prepared.disposition(), AtomicCommitDisposition::Committed); - - let outbound_event = signed_event("atomic outbound", 1_800_001_001); - let signed = store - .commit(atomic_commit( - 2, - 110, - AtomicWorkflow::Signed(Box::new(CommitSigned::new( - instance(1), - JournalRevision::INITIAL, - outbound_event.clone(), - ))), - )) - .await - .expect("signed commit"); - assert!(matches!( - signed.outcome(), - AtomicCommitOutcome::Signed { .. } - )); - - let replay = store - .commit(prepared_request.clone()) - .await - .expect("prepared replay"); - assert_eq!(replay.disposition(), AtomicCommitDisposition::Replay); - assert_eq!(replay.outcome(), prepared.outcome()); - let conflicting = AtomicCommit::new( - prepared_request.commit_id(), - AtomicCommitDigest::new([99; 32]), - 100, - AtomicWorkflow::Prepared(prepare(1, 100)), - ) - .expect("conflicting request"); - assert_eq!( - store.commit(conflicting).await, - Err(Error::AtomicCommitConflict) + let request = commit( + 1, + 1, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new( + admission(90), + Some(checkpoint()), + ))), ); - - let outbound_admission = admission(outbound_event.clone(), 120); - let enqueue = EnqueueOutboxItem::new( - OutboxItemId::new([1; 16]).expect("item id"), - instance(1), - DeliveryPlanDigest::new([1; 32]), - delivery_request(outbound_event.clone()), - 120, - ) - .expect("enqueue"); - let enqueued = store - .commit(atomic_commit( - 3, - 120, - AtomicWorkflow::Enqueued(Box::new( - CommitEnqueued::new( - instance(1), - JournalRevision::new(2).expect("revision"), - outbound_admission, - enqueue, - 120, - ) - .expect("enqueued workflow"), - )), - )) - .await - .expect("enqueue commit"); - assert!(matches!( - enqueued.outcome(), - AtomicCommitOutcome::Enqueued { .. } - )); - - let claimed = store - .claim( - ClaimOutboxItems::new( - LeaseOwner::parse("atomic-worker").expect("owner"), - LeaseId::new([9; 16]).expect("lease seed"), - 130, - 200, - 1, - ) - .expect("claim request"), - ) - .await - .expect("claim") - .pop() - .expect("claimed item"); - let delivery_receipt = DeliveryReceipt::for_request( - claimed.record().request(), - claimed - .record() - .request() - .target_set() - .targets() - .iter() - .cloned() - .map(|target| DeliveryTargetReceipt::attempted(target, DeliveryOutcome::accepted())) - .collect(), - ) - .expect("delivery receipt"); - let delivered = store - .commit(atomic_commit( - 4, - 140, - AtomicWorkflow::Delivered(Box::new( - DeliveryAttemptEvidence::new( - claimed.record().item_id(), - claimed.lease().id(), - claimed.record().revision(), - DeliveryAttempt::FIRST, - delivery_receipt, - 140, - ) - .expect("delivery evidence"), - )), - )) - .await - .expect("delivery commit"); + let committed = store.commit(request.clone()).await.expect("commit"); + assert_eq!(committed.disposition(), AtomicCommitDisposition::Committed); assert!(matches!( - delivered.outcome(), - AtomicCommitOutcome::Delivered { .. } - )); - - let inbound_event = signed_event("atomic inbound", 1_800_001_002); - let ingested = store - .commit(atomic_commit( - 5, - 150, - AtomicWorkflow::Ingested(Box::new(CommitIngested::new( - admission(inbound_event, 150), - Some(checkpoint(2, 150)), - ))), - )) - .await - .expect("ingest commit"); - assert!(matches!( - ingested.outcome(), + committed.outcome(), AtomicCommitOutcome::Ingested { projection: Some(_), .. } )); - for (byte, expected) in [ - (1, &prepared), - (2, &signed), - (3, &enqueued), - (4, &delivered), - (5, &ingested), - ] { - let stored = store - .receipt(AtomicCommitId::new([byte; 16]).expect("commit id")) - .await - .expect("receipt lookup") - .expect("stored receipt"); - assert_eq!(stored, *expected); - } - } - - #[tokio::test] - async fn every_ingest_mutation_fault_rolls_back_all_prior_statements() { - let fault_points = [ - ( - "CREATE TEMP TRIGGER atomic_fault_0 BEFORE UPDATE ON radroots_runtime_source_generations BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "radroots_runtime_source_generations", - ), - ( - "CREATE TEMP TRIGGER atomic_fault_1 BEFORE INSERT ON radroots_runtime_events BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "radroots_runtime_events", - ), - ( - "CREATE TEMP TRIGGER atomic_fault_2 BEFORE INSERT ON radroots_runtime_event_provenance BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "radroots_runtime_event_provenance", - ), - ( - "CREATE TEMP TRIGGER atomic_fault_3 BEFORE INSERT ON radroots_runtime_projection_checkpoints BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "radroots_runtime_projection_checkpoints", - ), - ( - "CREATE TEMP TRIGGER atomic_fault_4 BEFORE INSERT ON radroots_runtime_atomic_commits BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "radroots_runtime_atomic_commits", - ), - ]; - for (index, (trigger, table)) in fault_points.into_iter().enumerate() { - let store = store(EventStoreMode::ReadWrite).await; - sqlx::raw_sql(trigger) - .execute(store.pool()) - .await - .expect("fault trigger"); - let event = signed_event("fault injection", 1_800_002_000 + index as u64); - let result = store - .commit(atomic_commit( - 20 + u8::try_from(index).expect("fault index"), - 200, - AtomicWorkflow::Ingested(Box::new(CommitIngested::new( - admission(event, 200), - Some(checkpoint(1, 200)), - ))), - )) - .await; - assert_eq!(result, Err(Error::BackendUnavailable), "fault at {table}"); - for (durable_table, count_query) in [ - ( - "radroots_runtime_events", - "SELECT COUNT(*) FROM radroots_runtime_events", - ), - ( - "radroots_runtime_event_provenance", - "SELECT COUNT(*) FROM radroots_runtime_event_provenance", - ), - ( - "radroots_runtime_projection_checkpoints", - "SELECT COUNT(*) FROM radroots_runtime_projection_checkpoints", - ), - ( - "radroots_runtime_atomic_commits", - "SELECT COUNT(*) FROM radroots_runtime_atomic_commits", - ), - ] { - let count = sqlx::query_scalar::<_, i64>(count_query) - .fetch_one(store.pool()) - .await - .expect("durable row count"); - assert_eq!(count, 0, "partial row remained in {durable_table}"); - } - let sequence = sqlx::query_scalar::<_, i64>( - "SELECT sequence_head FROM radroots_runtime_source_generations", - ) - .fetch_one(store.pool()) + let replay = store.commit(request.clone()).await.expect("replay"); + assert_eq!(replay.disposition(), AtomicCommitDisposition::Replay); + assert_eq!(replay.outcome(), committed.outcome()); + let reconstructed = store + .receipt(request.commit_id()) .await - .expect("sequence head"); - assert_eq!(sequence, 0, "sequence advanced at {table}"); - } - } - - #[tokio::test] - async fn journal_and_enqueue_faults_restore_the_precommit_state() { - let journal_store = store(EventStoreMode::ReadWrite).await; - sqlx::raw_sql( - "CREATE TEMP TRIGGER atomic_journal_insert_fault BEFORE INSERT ON radroots_runtime_journal_operations BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - ) - .execute(journal_store.pool()) - .await - .expect("journal insert fault"); - assert_eq!( - journal_store - .commit(atomic_commit( - 50, - 500, - AtomicWorkflow::Prepared(prepare(50, 500)), - )) - .await, - Err(Error::BackendUnavailable) + .expect("receipt lookup") + .expect("receipt"); + assert_eq!(reconstructed.outcome(), committed.outcome()); + + let conflict = commit( + 1, + 2, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new(admission(90), None))), ); - assert!( - journal_store - .operation(instance(50)) - .await - .expect("journal lookup") - .is_none() + assert_eq!( + store.commit(conflict).await, + Err(Error::AtomicCommitConflict) ); - - for (index, trigger) in [ - "CREATE TEMP TRIGGER atomic_outbox_item_fault BEFORE INSERT ON radroots_runtime_outbox_items BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "CREATE TEMP TRIGGER atomic_outbox_target_fault BEFORE INSERT ON radroots_runtime_outbox_targets BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "CREATE TEMP TRIGGER atomic_journal_update_fault BEFORE UPDATE ON radroots_runtime_journal_operations BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - ] - .into_iter() - .enumerate() - { - let store = store(EventStoreMode::ReadWrite).await; - let operation_byte = 60 + u8::try_from(index).expect("case index"); - let outbound_event = - signed_event("enqueue fault", 1_800_003_000 + index as u64); - let prepared = store - .prepare(prepare(operation_byte, 600)) - .await - .expect("prepare") - .record() - .clone(); - let signed = store - .transition(JournalTransition::signed( - instance(operation_byte), - prepared.revision(), - *outbound_event.id(), - )) - .await - .expect("sign journal"); - sqlx::raw_sql(trigger) - .execute(store.pool()) - .await - .expect("enqueue fault trigger"); - let enqueue = EnqueueOutboxItem::new( - OutboxItemId::new([operation_byte; 16]).expect("item id"), - instance(operation_byte), - DeliveryPlanDigest::new([operation_byte; 32]), - delivery_request(outbound_event.clone()), - 610, - ) - .expect("enqueue"); - let request = atomic_commit( - 70 + u8::try_from(index).expect("case index"), - 610, - AtomicWorkflow::Enqueued(Box::new( - CommitEnqueued::new( - instance(operation_byte), - signed.revision(), - admission(outbound_event, 610), - enqueue, - 610, - ) - .expect("enqueue workflow"), - )), - ); - assert_eq!(store.commit(request).await, Err(Error::BackendUnavailable)); - let durable = store - .operation(instance(operation_byte)) - .await - .expect("journal lookup") - .expect("signed journal"); - assert_eq!(durable.state().stage(), JournalStage::Signed); - assert_eq!(durable.revision(), signed.revision()); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_events") - .fetch_one(store.pool()) - .await - .expect("event count"), - 0 - ); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_outbox_items") - .fetch_one(store.pool()) - .await - .expect("outbox count"), - 0 - ); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_atomic_commits") - .fetch_one(store.pool()) - .await - .expect("commit count"), - 0 - ); - } - } - - #[tokio::test] - async fn delivery_faults_restore_the_claimed_outbox_record() { - for (index, trigger) in [ - "CREATE TEMP TRIGGER atomic_delivery_update_fault BEFORE UPDATE ON radroots_runtime_outbox_items BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - "CREATE TEMP TRIGGER atomic_delivery_evidence_fault BEFORE INSERT ON radroots_runtime_delivery_evidence BEGIN SELECT RAISE(ABORT, 'atomic fault'); END", - ] - .into_iter() - .enumerate() - { - let store = store(EventStoreMode::ReadWrite).await; - let operation_byte = 80 + u8::try_from(index).expect("case index"); - store - .prepare(prepare(operation_byte, 800)) - .await - .expect("prepare"); - let request = delivery_request(signed_event( - "delivery fault", - 1_800_004_000 + index as u64, - )); - store - .enqueue( - EnqueueOutboxItem::new( - OutboxItemId::new([operation_byte; 16]).expect("item id"), - instance(operation_byte), - DeliveryPlanDigest::new([operation_byte; 32]), - request.clone(), - 810, - ) - .expect("enqueue"), - ) - .await - .expect("enqueue"); - let claimed = store - .claim( - ClaimOutboxItems::new( - LeaseOwner::parse("atomic-fault-worker").expect("owner"), - LeaseId::new([operation_byte; 16]).expect("lease seed"), - 820, - 900, - 1, - ) - .expect("claim request"), - ) - .await - .expect("claim") - .pop() - .expect("claimed item"); - sqlx::raw_sql(trigger) - .execute(store.pool()) - .await - .expect("delivery fault trigger"); - let receipt = DeliveryReceipt::for_request( - &request, - request - .target_set() - .targets() - .iter() - .cloned() - .map(|target| { - DeliveryTargetReceipt::attempted(target, DeliveryOutcome::accepted()) - }) - .collect(), - ) - .expect("delivery receipt"); - let evidence = DeliveryAttemptEvidence::new( - claimed.record().item_id(), - claimed.lease().id(), - claimed.record().revision(), - DeliveryAttempt::FIRST, - receipt, - 830, - ) - .expect("delivery evidence"); - assert_eq!( - store - .commit(atomic_commit( - 90 + u8::try_from(index).expect("case index"), - 830, - AtomicWorkflow::Delivered(Box::new(evidence)), - )) - .await, - Err(Error::BackendUnavailable) - ); - assert_eq!( - store - .item(claimed.record().item_id()) - .await - .expect("item lookup") - .expect("claimed record"), - *claimed.record() - ); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM radroots_runtime_delivery_evidence") - .fetch_one(store.pool()) - .await - .expect("evidence count"), - 0 - ); - } } #[tokio::test] async fn read_only_mode_rejects_atomic_mutation() { let store = store(EventStoreMode::ReadOnly).await; - assert_eq!( - store - .commit(atomic_commit( - 40, - 400, - AtomicWorkflow::Prepared(prepare(40, 400)), - )) - .await, - Err(Error::BackendUnavailable) + let request = commit( + 2, + 2, + AtomicWorkflow::Ingested(Box::new(CommitIngested::new(admission(90), None))), ); + assert_eq!(store.commit(request).await, Err(Error::BackendUnavailable)); } #[test] - fn rollback_failure_never_replaces_the_primary_failure() { - let primary = Error::JournalRevisionConflict; + fn receipt_decoder_rejects_retired_and_trailing_payloads() { assert_eq!( - preserve_primary(primary.clone(), Err::<(), _>("rollback failed")), - primary + decode_outcome(&vec![0; RECEIPT_MAX_BYTES + 1]), + Err(Error::AtomicCommitFailed) ); - } - - #[test] - fn receipt_decoding_rejects_trailing_or_unknown_data() { - let record = prepare(50, 500).into_record().expect("record"); - let outcome = AtomicCommitOutcome::Prepared { journal: record }; - let mut encoded = encode_outcome(&outcome).expect("encoded outcome"); - encoded.push(0); + assert_eq!(decode_outcome(&[0]), Err(Error::AtomicCommitFailed)); assert_eq!( - decode_outcome(encoded.as_slice()), + decode_outcome(&[RECEIPT_FORMAT_VERSION, 0]), Err(Error::AtomicCommitFailed) ); - assert_eq!(decode_outcome(&[99, 0]), Err(Error::AtomicCommitFailed)); + let outcome = AtomicCommitOutcome::Ingested { + admission: AdmissionReceipt::new( + radroots_storage::event::EventId::from_bytes([1; 32]), + EventPosition::new( + SourceGeneration::new([2; 32]).expect("generation"), + EventSequence::new(1).expect("sequence"), + ), + AdmissionStage::Raw, + AdmissionDisposition::Inserted, + ), + projection: None, + }; + let mut bytes = encode_outcome(&outcome).expect("encode"); + bytes.push(0); + assert_eq!(decode_outcome(&bytes), Err(Error::AtomicCommitFailed)); } } diff --git a/crates/storage_sqlite/src/authored.rs b/crates/storage_sqlite/src/authored.rs @@ -1159,19 +1159,28 @@ where mod tests { use super::*; use crate::migration::runtime::{MIGRATIONS, migration_sql}; - use core::num::NonZeroU64; + use core::num::{NonZeroU32, NonZeroU64}; use radroots_event::{GenericEventDraft, SignedEvent, wire::v1::Nip01EventWire}; use radroots_event_codec::authoring::AuthoredEventPlan; use radroots_storage::{ - authored::{AuthoredArtifact, WorkClaim}, - authored_atomic::{ApplySignedArtifact, ClaimAuthoredWork, PrepareAuthoredOperation}, + authored::{ + AdmissionState, AuthoredArtifact, FailureClass, RetrySchedule, WorkClaim, WorkFailure, + WorkPhase, + }, + authored_atomic::{ + ApplyAdmissionResult, ApplyDeliveryAttempt, ApplySignedArtifact, ApplyWorkFailure, + AuthoredWorkTarget, CancelAuthoredTarget, CancelAuthoredWork, ClaimAuthoredWork, + PrepareAuthoredOperation, + }, authored_delivery::{AuthoredDeliveryIntent, AuthoredDeliveryPlan}, event::SourceGeneration, status::EventStoreMode, }; use radroots_transport::{ - Target, TargetSet, + DeliveryReceipt, SinkFailure, Target, TargetSet, + outcome::{DeliveryOutcome, Retryability}, policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::DeliveryTargetReceipt, }; use sqlx::sqlite::SqlitePoolOptions; @@ -1280,6 +1289,127 @@ mod tests { (AuthoredAtomicCommand::Prepare(command), event_plan) } + fn fence(claim: &WorkClaim) -> WorkFence { + WorkFence::new(*claim.token(), claim.generation(), claim.row_revision()).unwrap() + } + + async fn prepared_store() -> (SqliteStorage, AuthoredEventPlan) { + let store = store(EventStoreMode::ReadWrite).await; + let (command, plan) = prepare(); + store.execute_authored(command).await.unwrap(); + (store, plan) + } + + async fn signed_store() -> SqliteStorage { + let (store, event_plan) = prepared_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let claim = WorkClaim::new( + [4; 16], + "sqlite-signer", + NonZeroU64::MIN, + 11, + 50, + artifact.revision(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + claim.clone(), + ))) + .await + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplySigned( + ApplySignedArtifact::new(ids().1, fence(&claim), signed(&event_plan), 12).unwrap(), + )) + .await + .unwrap(); + store + } + + async fn claim_admission(store: &SqliteStorage) -> WorkClaim { + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let claim = WorkClaim::new( + [5; 16], + "sqlite-admission", + NonZeroU64::new(2).unwrap(), + 13, + 50, + artifact.revision(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactAdmission(ids().1), + claim.clone(), + ))) + .await + .unwrap(); + claim + } + + async fn claim_delivery(store: &SqliteStorage) -> WorkClaim { + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + let claim = WorkClaim::new( + [6; 16], + "sqlite-delivery", + NonZeroU64::new(3).unwrap(), + 13, + 50, + plan.revision(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::DeliveryPlan(ids().2), + claim.clone(), + ))) + .await + .unwrap(); + claim + } + + async fn satisfied_store() -> SqliteStorage { + let store = signed_store().await; + let delivery_claim = claim_delivery(&store).await; + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + let request = plan.request().unwrap(); + let receipt = DeliveryReceipt::for_request( + request, + request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| DeliveryTargetReceipt::attempted(target, DeliveryOutcome::accepted())) + .collect(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyDelivery( + ApplyDeliveryAttempt::new( + ids().2, + fence(&delivery_claim), + DeliveryAttemptOutcome::Receipt(receipt), + None, + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + store + } + #[tokio::test] async fn authored_workflows_match_memory_replay_and_exact_binding_semantics() { let store = store(EventStoreMode::ReadWrite).await; @@ -1478,6 +1608,837 @@ mod tests { } #[tokio::test] + async fn authored_admission_and_delivery_commands_round_trip_exact_state() { + let store = signed_store().await; + let admission_claim = claim_admission(&store).await; + store + .execute_authored(AuthoredAtomicCommand::ApplyAdmission( + ApplyAdmissionResult::new( + ids().1, + fence(&admission_claim), + AdmissionState::Inserted, + None, + None, + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_artifact(ids().1) + .await + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Inserted + ); + + let delivery_claim = claim_delivery(&store).await; + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + let request = plan.request().unwrap(); + let receipt = DeliveryReceipt::for_request( + request, + request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| DeliveryTargetReceipt::attempted(target, DeliveryOutcome::accepted())) + .collect(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyDelivery( + ApplyDeliveryAttempt::new( + ids().2, + fence(&delivery_claim), + DeliveryAttemptOutcome::Receipt(receipt), + None, + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::Satisfied + ); + } + + #[tokio::test] + async fn authored_failure_commands_persist_each_phase_and_retry_class() { + let (store, _) = prepared_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let claim = WorkClaim::new( + [4; 16], + "sqlite-signer", + NonZeroU64::MIN, + 11, + 50, + artifact.revision(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + claim.clone(), + ))) + .await + .unwrap(); + let failure = WorkFailure::new( + "terminal_signer", + WorkPhase::Signing, + FailureClass::Terminal, + None, + None, + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyFailure( + ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence(&claim), + failure, + None, + 12, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_artifact(ids().1) + .await + .unwrap() + .unwrap() + .signing_state(), + SigningState::FailedTerminal + ); + + let store = signed_store().await; + let claim = claim_admission(&store).await; + let failure = WorkFailure::new( + "temporary_admission", + WorkPhase::Admission, + FailureClass::Retryable, + Some(30), + None, + ) + .unwrap(); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, failure.clone()).unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyFailure( + ApplyWorkFailure::new( + AuthoredWorkTarget::Artifact(ids().1), + fence(&claim), + failure, + Some(retry), + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_artifact(ids().1) + .await + .unwrap() + .unwrap() + .admission_state(), + AdmissionState::Retryable + ); + + let store = signed_store().await; + let claim = claim_delivery(&store).await; + let failure = WorkFailure::new( + "relay_terminal", + WorkPhase::Delivery, + FailureClass::Terminal, + None, + Some("terminal".to_owned()), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyFailure( + ApplyWorkFailure::new( + AuthoredWorkTarget::DeliveryPlan(ids().2), + fence(&claim), + failure, + None, + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::FailedTerminal + ); + } + + #[tokio::test] + async fn authored_sink_failure_and_every_cancellation_target_are_durable() { + let store = signed_store().await; + let claim = claim_delivery(&store).await; + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + let failure = SinkFailure::for_request( + plan.request().unwrap(), + "relay_unavailable", + Retryability::Retryable, + Some(30), + None, + Vec::new(), + ) + .unwrap(); + let work_failure = WorkFailure::new( + "relay_unavailable", + WorkPhase::Delivery, + FailureClass::Retryable, + Some(30), + None, + ) + .unwrap(); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, work_failure).unwrap(); + store + .execute_authored(AuthoredAtomicCommand::ApplyDelivery( + ApplyDeliveryAttempt::new( + ids().2, + fence(&claim), + DeliveryAttemptOutcome::SinkFailure(failure), + Some(retry), + 14, + ) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!( + store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap() + .state(), + AuthoredDeliveryState::Retryable + ); + + let (store, _) = prepared_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Cancel( + CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactSigning(ids().1), + artifact.revision(), + 11, + ) + .unwrap(), + )) + .await + .unwrap(); + + let store = signed_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Cancel( + CancelAuthoredWork::new( + CancelAuthoredTarget::ArtifactAdmission(ids().1), + artifact.revision(), + 13, + ) + .unwrap(), + )) + .await + .unwrap(); + + let store = signed_store().await; + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Cancel( + CancelAuthoredWork::new( + CancelAuthoredTarget::DeliveryPlan(ids().2), + plan.revision(), + 13, + ) + .unwrap(), + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn authored_missing_rows_conflicts_and_cross_table_integrity_fail_closed() { + let store = store(EventStoreMode::ReadWrite).await; + assert!(store.authored_operation(ids().0).await.unwrap().is_none()); + assert!(store.authored_artifact(ids().1).await.unwrap().is_none()); + assert!( + store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .is_none() + ); + assert!( + store + .authored_receipt(AtomicCommitId::new([9; 16]).unwrap()) + .await + .unwrap() + .is_none() + ); + + let (command, _) = prepare(); + store.execute_authored(command.clone()).await.unwrap(); + assert_eq!( + store.execute_authored(command).await.unwrap().disposition(), + AtomicCommitDisposition::Replay + ); + let (conflict, _) = prepare(); + let AuthoredAtomicCommand::Prepare(value) = conflict else { + unreachable!() + }; + let conflict = PrepareAuthoredOperation::new( + value.operation().clone(), + value.artifacts().to_vec(), + value.delivery_plans().to_vec(), + AtomicCommitDigest::new([8; 32]), + value.requested_at_unix_ms(), + ) + .unwrap(); + assert_eq!( + store + .execute_authored(AuthoredAtomicCommand::Prepare(conflict)) + .await, + Err(Error::AtomicCommitConflict) + ); + + sqlx::query("DELETE FROM radroots_runtime_authored_artifacts WHERE artifact_id = ?") + .bind(ids().1.as_bytes().as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_operation(ids().0).await, + Err(Error::InvalidAuthoredOperation) + ); + } + + #[tokio::test] + async fn denormalized_authored_columns_are_verified_against_canonical_snapshots() { + let (store, _) = prepared_store().await; + + macro_rules! reject_operation_shadow { + ($update:literal, $restore:literal) => {{ + sqlx::query($update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_operation(ids().0).await, + Err(Error::InvalidAuthoredOperation) + ); + sqlx::query($restore).execute(&store.pool).await.unwrap(); + }}; + } + reject_operation_shadow!( + "UPDATE radroots_runtime_authored_operations SET artifact_count = 2", + "UPDATE radroots_runtime_authored_operations SET artifact_count = 1" + ); + reject_operation_shadow!( + "UPDATE radroots_runtime_authored_operations SET created_at_unix_ms = 9", + "UPDATE radroots_runtime_authored_operations SET created_at_unix_ms = 10" + ); + reject_operation_shadow!( + "UPDATE radroots_runtime_authored_operations SET updated_at_unix_ms = 11", + "UPDATE radroots_runtime_authored_operations SET updated_at_unix_ms = 10" + ); + reject_operation_shadow!( + "UPDATE radroots_runtime_authored_operations SET revision = 2", + "UPDATE radroots_runtime_authored_operations SET revision = 1" + ); + + macro_rules! reject_artifact_shadow { + ($update:literal, $restore:literal) => {{ + sqlx::query($update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + sqlx::query($restore).execute(&store.pool).await.unwrap(); + }}; + } + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET ordinal = 1", + "UPDATE radroots_runtime_authored_artifacts SET ordinal = 0" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET signing_state = 'retryable'", + "UPDATE radroots_runtime_authored_artifacts SET signing_state = 'planned'" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET admission_state = 'inserted'", + "UPDATE radroots_runtime_authored_artifacts SET admission_state = 'pending'" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET retry_not_before_unix_ms = 30", + "UPDATE radroots_runtime_authored_artifacts SET retry_not_before_unix_ms = NULL" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET last_failure_code = 'forged'", + "UPDATE radroots_runtime_authored_artifacts SET last_failure_code = NULL" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET created_at_unix_ms = 9", + "UPDATE radroots_runtime_authored_artifacts SET created_at_unix_ms = 10" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET updated_at_unix_ms = 11", + "UPDATE radroots_runtime_authored_artifacts SET updated_at_unix_ms = 10" + ); + reject_artifact_shadow!( + "UPDATE radroots_runtime_authored_artifacts SET revision = 2", + "UPDATE radroots_runtime_authored_artifacts SET revision = 1" + ); + + macro_rules! reject_plan_shadow { + ($update:literal, $restore:literal) => {{ + sqlx::query($update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query($restore).execute(&store.pool).await.unwrap(); + }}; + } + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_plans SET attempt_count = 1", + "UPDATE radroots_runtime_authored_delivery_plans SET attempt_count = 0" + ); + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_plans SET last_failure_code = 'forged'", + "UPDATE radroots_runtime_authored_delivery_plans SET last_failure_code = NULL" + ); + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_plans SET created_at_unix_ms = 9", + "UPDATE radroots_runtime_authored_delivery_plans SET created_at_unix_ms = 10" + ); + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_plans SET updated_at_unix_ms = 11", + "UPDATE radroots_runtime_authored_delivery_plans SET updated_at_unix_ms = 10" + ); + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_plans SET revision = 2", + "UPDATE radroots_runtime_authored_delivery_plans SET revision = 1" + ); + reject_plan_shadow!( + "UPDATE radroots_runtime_authored_delivery_targets SET ordinal = 9 WHERE ordinal = 0", + "UPDATE radroots_runtime_authored_delivery_targets SET ordinal = 0 WHERE ordinal = 9" + ); + } + + #[tokio::test] + async fn authored_binary_claim_attempt_and_receipt_shadows_fail_closed() { + assert_eq!( + decode_snapshot::<serde_json::Value>(Vec::new()), + Err(Error::AtomicCommitFailed) + ); + assert_eq!( + decode_snapshot::<serde_json::Value>(vec![0; SNAPSHOT_MAX_BYTES + 1]), + Err(Error::AtomicCommitFailed) + ); + assert_eq!( + encode_snapshot(&"x".repeat(SNAPSHOT_MAX_BYTES + 1)), + Err(Error::AtomicCommitFailed) + ); + + let (store, _) = prepared_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let plan_wire = artifact.plan().unwrap().wire_json().to_vec(); + sqlx::query("UPDATE radroots_runtime_authored_artifacts SET plan_wire = x'7b7d'") + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + sqlx::query("UPDATE radroots_runtime_authored_artifacts SET plan_wire = ?") + .bind(plan_wire.as_slice()) + .execute(&store.pool) + .await + .unwrap(); + sqlx::query( + "UPDATE radroots_runtime_authored_artifacts + SET origin = 'imported_signed', plan_wire = NULL, + signing_state = 'signed', signed_raw_json = x'7b7d', signed_raw_sha256 = zeroblob(32)", + ) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + + let (store, _) = prepared_store().await; + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + sqlx::query( + "UPDATE radroots_runtime_authored_delivery_plans SET request_digest = zeroblob(32)", + ) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query( + "UPDATE radroots_runtime_authored_delivery_plans SET request_digest = ?, state = 'cancelled'", + ) + .bind(plan.request_digest().as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query("UPDATE radroots_runtime_authored_delivery_plans SET state = 'pending'") + .execute(&store.pool) + .await + .unwrap(); + let target = plan.intent().target_set().targets()[0].clone(); + sqlx::query( + "UPDATE radroots_runtime_authored_delivery_targets + SET target_fingerprint = 'forged' WHERE ordinal = 0", + ) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query( + "UPDATE radroots_runtime_authored_delivery_targets + SET target_fingerprint = ?, target_snapshot = x'7b7d' WHERE ordinal = 0", + ) + .bind(target.fingerprint().as_str()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::AtomicCommitFailed) + ); + + let (store, _) = prepared_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let claim = WorkClaim::new( + [4; 16], + "sqlite-signer", + NonZeroU64::MIN, + 11, + 50, + artifact.revision(), + ) + .unwrap(); + store + .execute_authored(AuthoredAtomicCommand::Claim(ClaimAuthoredWork::new( + ClaimAuthoredTarget::ArtifactSigning(ids().1), + claim, + ))) + .await + .unwrap(); + for (update, restore) in [ + ( + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_token = zeroblob(16)", + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_token = x'04040404040404040404040404040404'", + ), + ( + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_generation = 2", + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_generation = 1", + ), + ( + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_revision = 2", + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_revision = 1", + ), + ( + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_expires_at_unix_ms = 51", + "UPDATE radroots_runtime_authored_artifacts SET signing_claim_expires_at_unix_ms = 50", + ), + ] { + sqlx::query(update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + sqlx::query(restore).execute(&store.pool).await.unwrap(); + } + + let store = signed_store().await; + claim_delivery(&store).await; + for (update, restore) in [ + ( + "UPDATE radroots_runtime_authored_delivery_plans SET claim_token = zeroblob(16)", + "UPDATE radroots_runtime_authored_delivery_plans SET claim_token = x'06060606060606060606060606060606'", + ), + ( + "UPDATE radroots_runtime_authored_delivery_plans SET claim_generation = 4", + "UPDATE radroots_runtime_authored_delivery_plans SET claim_generation = 3", + ), + ( + "UPDATE radroots_runtime_authored_delivery_plans SET claim_revision = 99", + "UPDATE radroots_runtime_authored_delivery_plans SET claim_revision = 2", + ), + ( + "UPDATE radroots_runtime_authored_delivery_plans SET claim_expires_at_unix_ms = 51", + "UPDATE radroots_runtime_authored_delivery_plans SET claim_expires_at_unix_ms = 50", + ), + ] { + sqlx::query(update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query(restore).execute(&store.pool).await.unwrap(); + } + + let store = signed_store().await; + let artifact = store.authored_artifact(ids().1).await.unwrap().unwrap(); + let signed = artifact.signed().expect("signed artifact"); + sqlx::query("UPDATE radroots_runtime_authored_artifacts SET signed_raw_json = x'7b7d'") + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + sqlx::query( + "UPDATE radroots_runtime_authored_artifacts + SET signed_raw_json = ?, signed_raw_sha256 = zeroblob(32)", + ) + .bind(signed.event().raw_json().as_bytes()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + + let store = signed_store().await; + claim_admission(&store).await; + sqlx::query( + "UPDATE radroots_runtime_authored_artifacts + SET admission_claim_generation = admission_claim_generation + 1", + ) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + + let (store, _) = prepared_store().await; + sqlx::query("DELETE FROM radroots_runtime_authored_delivery_targets WHERE plan_id = ?") + .bind(ids().2.as_bytes().as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + let (store, _) = prepared_store().await; + sqlx::query( + "INSERT INTO radroots_runtime_authored_delivery_attempts ( + plan_id, attempt, satisfaction, recorded_at_unix_ms, outcome_snapshot + ) VALUES (?, 1, 'pending', 20, x'7b7d')", + ) + .bind(ids().2.as_bytes().as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + let (store, _) = prepared_store().await; + let other_target = Target::nostr_relay("wss://shadow.example").unwrap(); + sqlx::query( + "UPDATE radroots_runtime_authored_delivery_targets + SET target_snapshot = ? WHERE plan_id = ? AND ordinal = 0", + ) + .bind(encode_snapshot(&other_target).unwrap()) + .bind(ids().2.as_bytes().as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + let store = satisfied_store().await; + for (update, restore) in [ + ( + "UPDATE radroots_runtime_authored_delivery_attempts SET attempt = 2", + "UPDATE radroots_runtime_authored_delivery_attempts SET attempt = 1", + ), + ( + "UPDATE radroots_runtime_authored_delivery_attempts SET satisfaction = 'pending'", + "UPDATE radroots_runtime_authored_delivery_attempts SET satisfaction = 'satisfied'", + ), + ( + "UPDATE radroots_runtime_authored_delivery_attempts SET recorded_at_unix_ms = 15", + "UPDATE radroots_runtime_authored_delivery_attempts SET recorded_at_unix_ms = 14", + ), + ] { + sqlx::query(update).execute(&store.pool).await.unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + sqlx::query(restore).execute(&store.pool).await.unwrap(); + } + let plan = store + .authored_delivery_plan(ids().2) + .await + .unwrap() + .unwrap(); + let request = plan.request().unwrap(); + let different = DeliveryAttemptOutcome::Receipt( + DeliveryReceipt::for_request( + request, + request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| { + DeliveryTargetReceipt::attempted(target, DeliveryOutcome::unavailable()) + }) + .collect(), + ) + .unwrap(), + ); + sqlx::query("UPDATE radroots_runtime_authored_delivery_attempts SET outcome_snapshot = ?") + .bind(encode_snapshot(&different).unwrap()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + async fn disable_foreign_keys(store: &SqliteStorage) { + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&store.pool) + .await + .unwrap(); + } + + let (store, _) = prepared_store().await; + disable_foreign_keys(&store).await; + sqlx::query("UPDATE radroots_runtime_authored_operations SET operation_id = ?") + .bind([41; 16].as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store + .authored_operation(OperationInstanceId::new([41; 16]).unwrap()) + .await, + Err(Error::InvalidAuthoredOperation) + ); + + let (store, _) = prepared_store().await; + disable_foreign_keys(&store).await; + sqlx::query("UPDATE radroots_runtime_authored_artifacts SET artifact_id = ?") + .bind([42; 16].as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store + .authored_artifact(AuthoredArtifactId::new([42; 16]).unwrap()) + .await, + Err(Error::InvalidAuthoredArtifact) + ); + + let (store, _) = prepared_store().await; + disable_foreign_keys(&store).await; + sqlx::query("UPDATE radroots_runtime_authored_artifacts SET operation_id = ?") + .bind([43; 16].as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_artifact(ids().1).await, + Err(Error::InvalidAuthoredArtifact) + ); + + let (store, _) = prepared_store().await; + disable_foreign_keys(&store).await; + sqlx::query("UPDATE radroots_runtime_authored_delivery_plans SET plan_id = ?") + .bind([44; 16].as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store + .authored_delivery_plan(AuthoredDeliveryPlanId::new([44; 16]).unwrap()) + .await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + + let (store, _) = prepared_store().await; + disable_foreign_keys(&store).await; + sqlx::query("UPDATE radroots_runtime_authored_delivery_plans SET artifact_id = ?") + .bind([45; 16].as_slice()) + .execute(&store.pool) + .await + .unwrap(); + assert_eq!( + store.authored_delivery_plan(ids().2).await, + Err(Error::InvalidAuthoredDeliveryPlan) + ); + } + + #[tokio::test] async fn read_only_and_ready_query_plan_contracts_are_enforced() { let store = store(EventStoreMode::ReadOnly).await; assert_eq!( diff --git a/crates/storage_sqlite/src/journal/mod.rs b/crates/storage_sqlite/src/journal/mod.rs @@ -287,117 +287,6 @@ pub(crate) fn decode_record(row: &sqlx::sqlite::SqliteRow) -> Result<OperationRe .map_err(|_| Error::CorruptJournalRecord) } -pub(crate) fn encode_record_snapshot(record: &OperationRecord) -> Result<Vec<u8>, Error> { - let mut bytes = Vec::with_capacity(160); - bytes.push(1); - bytes.extend_from_slice(record.instance_id().as_bytes()); - snapshot_put_string(&mut bytes, record.operation_id().as_str())?; - snapshot_put_string(&mut bytes, record.idempotency_key().as_str())?; - bytes.extend_from_slice(record.input_digest().as_bytes()); - bytes.extend_from_slice(&record.prepared_at_unix_ms().to_be_bytes()); - bytes.extend_from_slice(&record.revision().get().to_be_bytes()); - match record.state() { - JournalState::Prepared => bytes.push(0), - JournalState::Signed { event_id } => { - bytes.push(1); - bytes.extend_from_slice(event_id.as_bytes()); - } - JournalState::Recoverable(recovery) => { - bytes.push(2); - snapshot_put_blob(&mut bytes, encode_recovery(recovery).as_slice())?; - } - JournalState::Committed { - event_id, - committed_at_unix_ms, - } => { - bytes.push(3); - bytes.extend_from_slice(event_id.as_bytes()); - bytes.extend_from_slice(&committed_at_unix_ms.to_be_bytes()); - } - } - bytes.push(match record.cancellation() { - CancellationState::NotRequested => 0, - CancellationState::CancelledBeforeCommit => 1, - CancellationState::ObservedAfterCommit => 2, - }); - Ok(bytes) -} - -pub(crate) fn decode_record_snapshot(bytes: &[u8]) -> Result<OperationRecord, Error> { - let mut offset = 0; - if take_byte(bytes, &mut offset)? != 1 { - return Err(Error::CorruptJournalRecord); - } - let instance_id = OperationInstanceId::new(take_array(bytes, &mut offset)?) - .map_err(|_| Error::CorruptJournalRecord)?; - let operation_id = OperationId::parse(snapshot_take_string(bytes, &mut offset)?) - .map_err(|_| Error::CorruptJournalRecord)?; - let idempotency_key = IdempotencyKey::parse(snapshot_take_string(bytes, &mut offset)?) - .map_err(|_| Error::CorruptJournalRecord)?; - let input_digest = IdempotencyDigest::new(take_array(bytes, &mut offset)?); - let prepared_at_unix_ms = u64::from_be_bytes(take_array(bytes, &mut offset)?); - let revision = JournalRevision::new(u64::from_be_bytes(take_array(bytes, &mut offset)?)) - .map_err(|_| Error::CorruptJournalRecord)?; - let state = match take_byte(bytes, &mut offset)? { - 0 => JournalState::Prepared, - 1 => JournalState::Signed { - event_id: EventId::from_bytes(take_array(bytes, &mut offset)?), - }, - 2 => JournalState::Recoverable(decode_recovery(snapshot_take_blob(bytes, &mut offset)?)?), - 3 => JournalState::Committed { - event_id: EventId::from_bytes(take_array(bytes, &mut offset)?), - committed_at_unix_ms: u64::from_be_bytes(take_array(bytes, &mut offset)?), - }, - _ => return Err(Error::CorruptJournalRecord), - }; - let cancellation = match take_byte(bytes, &mut offset)? { - 0 => CancellationState::NotRequested, - 1 => CancellationState::CancelledBeforeCommit, - 2 => CancellationState::ObservedAfterCommit, - _ => return Err(Error::CorruptJournalRecord), - }; - if offset != bytes.len() { - return Err(Error::CorruptJournalRecord); - } - OperationRecord::from_parts( - instance_id, - operation_id, - idempotency_key, - input_digest, - prepared_at_unix_ms, - revision, - state, - cancellation, - ) - .map_err(|_| Error::CorruptJournalRecord) -} - -fn snapshot_put_string(bytes: &mut Vec<u8>, value: &str) -> Result<(), Error> { - snapshot_put_blob(bytes, value.as_bytes()) -} - -fn snapshot_put_blob(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), Error> { - let length = u16::try_from(value.len()).map_err(|_| Error::CorruptJournalRecord)?; - bytes.extend_from_slice(&length.to_be_bytes()); - bytes.extend_from_slice(value); - Ok(()) -} - -fn snapshot_take_string<'a>(bytes: &'a [u8], offset: &mut usize) -> Result<&'a str, Error> { - core::str::from_utf8(snapshot_take_blob(bytes, offset)?) - .map_err(|_| Error::CorruptJournalRecord) -} - -fn snapshot_take_blob<'a>(bytes: &'a [u8], offset: &mut usize) -> Result<&'a [u8], Error> { - let length = usize::from(u16::from_be_bytes(take_array(bytes, offset)?)); - let end = offset - .checked_add(length) - .ok_or(Error::CorruptJournalRecord)?; - let value = bytes.get(*offset..end).ok_or(Error::CorruptJournalRecord)?; - *offset = end; - Ok(value) -} - type EncodedState = (&'static str, Option<Vec<u8>>, Option<Vec<u8>>, Option<u64>); fn encode_state(state: &JournalState) -> EncodedState { @@ -653,6 +542,29 @@ mod tests { .expect("prepare") } + #[test] + fn recovery_decoder_and_state_guard_reject_noncanonical_encodings() { + let event_id = EventId::from_bytes([42; 32]); + let recovery = RecoveryRecord::new( + RecoveryPoint::Signed { event_id }, + RecoveryReason::Interrupted, + 1, + None, + ) + .expect("recovery"); + assert_eq!( + decode_state("recoverable", None, Some(recovery.clone()), None), + Err(Error::CorruptJournalRecord) + ); + assert_eq!(decode_recovery(&[2]), Err(Error::CorruptJournalRecord)); + let mut encoded = encode_recovery(&recovery); + encoded.push(0); + assert_eq!( + decode_recovery(encoded.as_slice()), + Err(Error::CorruptJournalRecord) + ); + } + #[tokio::test] async fn prepare_replays_exact_identity_and_rejects_conflicts() { let store = store(EventStoreMode::ReadWrite).await; @@ -838,21 +750,5 @@ mod tests { read_only.prepare(prepare(instance(6), 6, 6, 600)).await, Err(Error::BackendUnavailable) ); - - let encoded = encode_record_snapshot(&prepared).expect("encode journal snapshot"); - for end in 0..encoded.len() { - let _ = decode_record_snapshot(&encoded[..end]); - } - let mut trailing = encoded.clone(); - trailing.push(0); - assert_eq!( - decode_record_snapshot(&trailing), - Err(Error::CorruptJournalRecord) - ); - for index in 0..encoded.len() { - let mut corrupt = encoded.clone(); - corrupt[index] ^= 0xff; - let _ = decode_record_snapshot(&corrupt); - } } } diff --git a/crates/storage_sqlite/src/migration/authored_v10.rs b/crates/storage_sqlite/src/migration/authored_v10.rs @@ -493,7 +493,19 @@ fn replay_evidence(plan: &mut AuthoredDeliveryPlan, legacy: &OutboxRecord) -> Re .find(|entry| entry.attempt().get() == attempt_number.saturating_add(1)) .map(|entry| entry.recorded_at_unix_ms()) .or(legacy.retry_not_before_unix_ms()) - .unwrap_or_else(|| legacy.updated_at_unix_ms().max(recorded_at)); + .map_or_else( + || { + legacy + .updated_at_unix_ms() + .max(recorded_at) + .checked_add(1) + .ok_or_else(metadata_error) + }, + Ok, + )?; + if next_at <= recorded_at { + return Err(metadata_error()); + } let failure = WorkFailure::new( "migrated_delivery_pending", WorkPhase::Delivery, @@ -716,7 +728,10 @@ mod tests { IdempotencyDigest, IdempotencyKey, JournalTransition, OperationId, OperationInstanceId, PrepareOperation, }, - outbox::{DeliveryPlanDigest, EnqueueOutboxItem, OutboxItemId}, + outbox::{ + ClaimOutboxItems, DeliveryAttempt, DeliveryAttemptEvidence, DeliveryOutcome, + DeliveryPlanDigest, EnqueueOutboxItem, LeaseId, LeaseOwner, OutboxItemId, + }, status::EventStoreMode, }; use radroots_transport::{ @@ -852,6 +867,51 @@ mod tests { .expect("committed transition"); } + async fn record_legacy_attempt( + store: &SqliteStorage, + outcome: DeliveryOutcome, + attempted: bool, + ) -> OutboxRecord { + let claimed = store + .claim( + ClaimOutboxItems::new( + LeaseOwner::parse("migration-worker").expect("lease owner"), + LeaseId::new([91; 16]).expect("lease id"), + 103, + 200, + 1, + ) + .expect("claim request"), + ) + .await + .expect("claim") + .pop() + .expect("claimed item"); + let target = claimed.record().request().target_set().targets()[0].clone(); + let target_receipt = if attempted { + DeliveryTargetReceipt::attempted(target, outcome) + } else { + DeliveryTargetReceipt::skipped(target, outcome).expect("skipped receipt") + }; + let receipt = + DeliveryReceipt::for_request(claimed.record().request(), vec![target_receipt]) + .expect("receipt"); + store + .record_attempt( + DeliveryAttemptEvidence::new( + claimed.record().item_id(), + claimed.lease().id(), + claimed.record().revision(), + DeliveryAttempt::FIRST, + receipt, + 150, + ) + .expect("attempt evidence"), + ) + .await + .expect("record attempt") + } + #[tokio::test] async fn complete_rows_preflight_and_migrate_with_exact_signed_bytes_and_evidence() { let store = v10_store().await; @@ -987,6 +1047,259 @@ mod tests { .expect("preserved version"), 10 ); + + drop(connection); + let store = v10_store().await; + let valid = event(); + seed_complete(&store, 14, valid.clone()).await; + let mut wire = valid.wire().clone(); + wire.sig = "ee".repeat(64); + let mismatched_raw = serde_json::to_vec(&wire).expect("mismatched raw"); + sqlx::query("DROP TRIGGER radroots_runtime_events_raw_update_guard") + .execute(&store.pool) + .await + .expect("remove update guard for corruption fixture"); + sqlx::query("UPDATE radroots_runtime_events SET signed_event = ? WHERE event_id = ?") + .bind(mismatched_raw) + .bind(valid.id().as_bytes().as_slice()) + .execute(&store.pool) + .await + .expect("stored raw mismatch fixture"); + let mut connection = store.pool.acquire().await.expect("connection"); + let report = inspect(&mut connection).await.expect("preflight").report; + assert_eq!(report.invalid_or_unsupported(), 1); + } + + #[tokio::test] + async fn malformed_event_encodings_and_identifiers_are_counted_fail_closed() { + for signed_event in [vec![0xff], b"not-json".to_vec()] { + let store = v10_store().await; + sqlx::query( + "INSERT INTO radroots_runtime_events ( + source_generation, source_sequence, event_id, admission_stage, + signed_event, admitted_at_unix_ms, updated_at_unix_ms + ) VALUES (?, 1, ?, 'raw', ?, 10, 10)", + ) + .bind(store.generation.as_bytes().as_slice()) + .bind([72; 32].as_slice()) + .bind(signed_event) + .execute(&store.pool) + .await + .expect("malformed event fixture"); + let mut connection = store.pool.acquire().await.expect("connection"); + let report = inspect(&mut connection).await.expect("preflight").report; + assert_eq!(report.invalid_or_unsupported(), 1); + } + + let store = v10_store().await; + sqlx::query( + "INSERT INTO radroots_runtime_events ( + source_generation, source_sequence, event_id, admission_stage, + signed_event, admitted_at_unix_ms, updated_at_unix_ms + ) VALUES (?, 1, ?, 'raw', ?, 10, 10)", + ) + .bind(store.generation.as_bytes().as_slice()) + .bind([73; 32].as_slice()) + .bind(event().raw_json().as_bytes()) + .execute(&store.pool) + .await + .expect("mismatched event fixture"); + let mut connection = store.pool.acquire().await.expect("connection"); + let report = inspect(&mut connection).await.expect("preflight").report; + assert_eq!(report.invalid_or_unsupported(), 1); + } + + #[tokio::test] + async fn committed_without_outbox_and_corrupt_journal_rows_are_blocked() { + let store = v10_store().await; + let prepared = prepare(&store, 6).await; + let signed = store + .transition(JournalTransition::signed( + prepared.instance_id(), + prepared.revision(), + *event().id(), + )) + .await + .expect("signed transition"); + store + .transition(JournalTransition::committed( + prepared.instance_id(), + signed.revision(), + *event().id(), + 102, + )) + .await + .expect("committed transition"); + let mut connection = store.pool.acquire().await.expect("connection"); + let report = inspect(&mut connection).await.expect("preflight").report; + assert_eq!(report.signed_without_complete_event(), 1); + + drop(connection); + let store = v10_store().await; + prepare(&store, 7).await; + sqlx::query( + "UPDATE radroots_runtime_journal_operations SET operation_id = X'FF' + WHERE instance_id = ?", + ) + .bind(operation_id(7).as_bytes().as_slice()) + .execute(&store.pool) + .await + .expect("corrupt journal fixture"); + let mut connection = store.pool.acquire().await.expect("connection"); + let report = inspect(&mut connection).await.expect("preflight").report; + assert_eq!(report.invalid_or_unsupported(), 1); + } + + #[tokio::test] + async fn eligible_and_retryable_legacy_evidence_replays_exactly() { + for (byte, outcome, attempted) in [ + (8, DeliveryOutcome::accepted(), true), + (9, DeliveryOutcome::unavailable(), true), + (10, DeliveryOutcome::unavailable(), false), + ] { + let store = v10_store().await; + seed_complete(&store, byte, event()).await; + let legacy = record_legacy_attempt(&store, outcome, attempted).await; + assert!(matches!( + legacy.stage(), + OutboxStage::Satisfied | OutboxStage::Retryable + )); + let mut connection = store.pool.acquire().await.expect("connection"); + let inspected = inspect(&mut connection).await.expect("preflight"); + assert!(inspected.report.is_eligible()); + for candidate in &inspected.candidates { + convert_candidate(candidate).expect("candidate conversion"); + } + crate::migration::migrate_runtime(&mut connection, crate::OpenMode::ReadWriteExisting) + .await + .expect("migration with delivery evidence"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT attempt_count FROM radroots_runtime_authored_delivery_plans", + ) + .fetch_one(&mut *connection) + .await + .expect("attempt count"), + 1 + ); + } + } + + #[tokio::test] + async fn apply_rejects_ineligible_preflight_and_blocked_ids_are_bounded() { + let store = v10_store().await; + prepare(&store, 10).await; + let mut connection = store.pool.acquire().await.expect("connection"); + let inspected = inspect(&mut connection).await.expect("preflight"); + let mut transaction = connection.begin().await.expect("transaction"); + assert!(matches!( + apply(&mut transaction, &inspected).await, + Err(Error::AuthoredMigrationBlocked { .. }) + )); + transaction.rollback().await.expect("rollback"); + + let mut blocked = Vec::new(); + push_blocked(&mut blocked, &[0; 15]); + assert!(blocked.is_empty()); + for byte in 0..=BLOCKED_IDENTIFIERS_MAX { + push_blocked(&mut blocked, &[u8::try_from(byte).unwrap_or(u8::MAX); 16]); + } + assert_eq!(blocked.len(), BLOCKED_IDENTIFIERS_MAX); + } + + #[tokio::test] + async fn apply_detects_metadata_races_and_import_count_drift() { + let store = v10_store().await; + seed_complete(&store, 11, event()).await; + let mut connection = store.pool.acquire().await.expect("connection"); + let inspected = inspect(&mut connection).await.expect("preflight"); + let mut transaction = connection.begin().await.expect("transaction"); + sqlx::raw_sql(runtime::migration_sql(11).expect("migration SQL")) + .execute(&mut *transaction) + .await + .expect("successor schema"); + sqlx::query( + "UPDATE radroots_runtime_events + SET admitted_contract_id = 'radroots.profile.metadata.v1', + admitted_registry_version = 7", + ) + .execute(&mut *transaction) + .await + .expect("concurrent metadata fixture"); + assert!(matches!( + apply(&mut transaction, &inspected).await, + Err(Error::SchemaMigrationFailed { .. }) + )); + transaction.rollback().await.expect("rollback"); + + drop(connection); + let store = v10_store().await; + seed_complete(&store, 12, event()).await; + let mut connection = store.pool.acquire().await.expect("connection"); + let mut inspected = inspect(&mut connection).await.expect("preflight"); + inspected.candidates.clear(); + let mut transaction = connection.begin().await.expect("transaction"); + sqlx::raw_sql(runtime::migration_sql(11).expect("migration SQL")) + .execute(&mut *transaction) + .await + .expect("successor schema"); + assert!(matches!( + apply(&mut transaction, &inspected).await, + Err(Error::SchemaMigrationFailed { .. }) + )); + transaction.rollback().await.expect("rollback"); + } + + #[tokio::test] + async fn apply_rejects_successor_foreign_key_corruption() { + let store = v10_store().await; + let mut connection = store.pool.acquire().await.expect("connection"); + let inspected = inspect(&mut connection).await.expect("preflight"); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await + .expect("disable fixture foreign keys"); + let mut transaction = connection.begin().await.expect("transaction"); + sqlx::raw_sql(runtime::migration_sql(11).expect("migration SQL")) + .execute(&mut *transaction) + .await + .expect("successor schema"); + sqlx::query( + "INSERT INTO radroots_runtime_authored_delivery_targets ( + plan_id, ordinal, target_fingerprint, target_snapshot + ) VALUES (?, 0, 'orphan', x'7b7d')", + ) + .bind([99; 16].as_slice()) + .execute(&mut *transaction) + .await + .expect("orphan fixture"); + assert!(matches!( + apply(&mut transaction, &inspected).await, + Err(Error::SchemaMigrationFailed { .. }) + )); + transaction.rollback().await.expect("rollback"); + } + + #[tokio::test] + async fn nonadvancing_legacy_retry_schedule_is_rejected() { + let store = v10_store().await; + seed_complete(&store, 13, event()).await; + let legacy = record_legacy_attempt(&store, DeliveryOutcome::unavailable(), true).await; + sqlx::query( + "UPDATE radroots_runtime_outbox_items SET retry_not_before_unix_ms = 150 + WHERE item_id = ?", + ) + .bind(legacy.item_id().as_bytes().as_slice()) + .execute(&store.pool) + .await + .expect("nonadvancing retry fixture"); + let mut connection = store.pool.acquire().await.expect("connection"); + let inspected = inspect(&mut connection).await.expect("preflight"); + assert!(inspected.report.is_eligible()); + assert!(matches!( + convert_candidate(&inspected.candidates[0]), + Err(Error::SchemaMigrationFailed { .. }) + )); } #[tokio::test] diff --git a/crates/storage_sqlite/src/outbox/mod.rs b/crates/storage_sqlite/src/outbox/mod.rs @@ -544,161 +544,6 @@ fn decode_lease(row: &sqlx::sqlite::SqliteRow) -> Result<Option<OutboxLease>, Er } } -pub(crate) fn encode_record_snapshot(record: &OutboxRecord) -> Result<Vec<u8>, Error> { - let mut bytes = Vec::with_capacity(256); - bytes.push(1); - bytes.extend_from_slice(record.item_id().as_bytes()); - bytes.extend_from_slice(record.operation_instance_id().as_bytes()); - bytes.extend_from_slice(record.plan_digest().as_bytes()); - put_blob(&mut bytes, encode_request(record.request())?.as_slice())?; - bytes.extend_from_slice(&record.revision().get().to_be_bytes()); - bytes.push(match record.stage() { - OutboxStage::Pending => 0, - OutboxStage::Leased => 1, - OutboxStage::Retryable => 2, - OutboxStage::Satisfied => 3, - OutboxStage::Exhausted => 4, - }); - match record.lease() { - Some(lease) => { - bytes.push(1); - bytes.extend_from_slice(lease.id().as_bytes()); - put_str(&mut bytes, lease.owner().as_str())?; - bytes.extend_from_slice(&lease.acquired_at_unix_ms().to_be_bytes()); - bytes.extend_from_slice(&lease.expires_at_unix_ms().to_be_bytes()); - } - None => bytes.push(0), - } - match record.last_attempt() { - Some(attempt) => { - bytes.push(1); - bytes.extend_from_slice(&attempt.get().to_be_bytes()); - } - None => bytes.push(0), - } - let evidence_count = - u32::try_from(record.evidence().len()).map_err(|_| Error::CorruptOutboxRecord)?; - bytes.extend_from_slice(&evidence_count.to_be_bytes()); - for evidence in record.evidence() { - put_str(&mut bytes, evidence.target().as_str())?; - bytes.extend_from_slice(&evidence.attempt().get().to_be_bytes()); - bytes.push(u8::from(evidence.was_attempted())); - put_blob(&mut bytes, encode_outcome(evidence.outcome())?.as_slice())?; - bytes.extend_from_slice(&evidence.recorded_at_unix_ms().to_be_bytes()); - } - bytes.push(match record.satisfaction() { - SatisfactionResult::Pending => 0, - SatisfactionResult::Satisfied => 1, - SatisfactionResult::Exhausted => 2, - }); - match record.retry_not_before_unix_ms() { - Some(value) => { - bytes.push(1); - bytes.extend_from_slice(&value.to_be_bytes()); - } - None => bytes.push(0), - } - bytes.extend_from_slice(&record.created_at_unix_ms().to_be_bytes()); - bytes.extend_from_slice(&record.updated_at_unix_ms().to_be_bytes()); - Ok(bytes) -} - -pub(crate) fn decode_record_snapshot(bytes: &[u8]) -> Result<OutboxRecord, Error> { - let mut cursor = Cursor::new(bytes); - if cursor.byte()? != 1 { - return Err(Error::CorruptOutboxRecord); - } - let item_id = OutboxItemId::new(cursor.array()?).map_err(|_| Error::CorruptOutboxRecord)?; - let operation_instance_id = - radroots_storage::journal::OperationInstanceId::new(cursor.array()?) - .map_err(|_| Error::CorruptOutboxRecord)?; - let plan_digest = DeliveryPlanDigest::new(cursor.array()?); - let request = decode_request(cursor.blob()?)?; - let revision = OutboxRevision::new(cursor.u64()?).map_err(|_| Error::CorruptOutboxRecord)?; - let stage = match cursor.byte()? { - 0 => OutboxStage::Pending, - 1 => OutboxStage::Leased, - 2 => OutboxStage::Retryable, - 3 => OutboxStage::Satisfied, - 4 => OutboxStage::Exhausted, - _ => return Err(Error::CorruptOutboxRecord), - }; - let lease = match cursor.byte()? { - 0 => None, - 1 => Some( - OutboxLease::new( - LeaseId::new(cursor.array()?).map_err(|_| Error::CorruptOutboxRecord)?, - LeaseOwner::parse(cursor.string()?).map_err(|_| Error::CorruptOutboxRecord)?, - cursor.u64()?, - cursor.u64()?, - ) - .map_err(|_| Error::CorruptOutboxRecord)?, - ), - _ => return Err(Error::CorruptOutboxRecord), - }; - let last_attempt = match cursor.byte()? { - 0 => None, - 1 => Some(DeliveryAttempt::new(cursor.u32()?).map_err(|_| Error::CorruptOutboxRecord)?), - _ => return Err(Error::CorruptOutboxRecord), - }; - let evidence_count = usize::try_from(cursor.u32()?).map_err(|_| Error::CorruptOutboxRecord)?; - if evidence_count > bytes.len() / 20 { - return Err(Error::CorruptOutboxRecord); - } - let mut evidence = Vec::with_capacity(evidence_count); - for _ in 0..evidence_count { - let target = - TargetFingerprint::parse(cursor.string()?).map_err(|_| Error::CorruptOutboxRecord)?; - let attempt = - DeliveryAttempt::new(cursor.u32()?).map_err(|_| Error::CorruptOutboxRecord)?; - let attempted = match cursor.byte()? { - 0 => false, - 1 => true, - _ => return Err(Error::CorruptOutboxRecord), - }; - let outcome = decode_outcome(cursor.blob()?)?; - let recorded_at_unix_ms = cursor.u64()?; - evidence.push( - TargetDeliveryEvidence::new(target, attempt, attempted, outcome, recorded_at_unix_ms) - .map_err(|_| Error::CorruptOutboxRecord)?, - ); - } - let satisfaction = match cursor.byte()? { - 0 => SatisfactionResult::Pending, - 1 => SatisfactionResult::Satisfied, - 2 => SatisfactionResult::Exhausted, - _ => return Err(Error::CorruptOutboxRecord), - }; - let retry_not_before_unix_ms = match cursor.byte()? { - 0 => None, - 1 => Some(cursor.u64()?), - _ => return Err(Error::CorruptOutboxRecord), - }; - let created_at_unix_ms = cursor.u64()?; - let updated_at_unix_ms = cursor.u64()?; - cursor.finish()?; - let enqueue = EnqueueOutboxItem::new( - item_id, - operation_instance_id, - plan_digest, - request, - created_at_unix_ms, - ) - .map_err(|_| Error::CorruptOutboxRecord)?; - OutboxRecord::from_durable_parts( - enqueue, - revision, - stage, - lease, - last_attempt, - evidence, - satisfaction, - retry_not_before_unix_ms, - updated_at_unix_ms, - ) - .map_err(|_| Error::CorruptOutboxRecord) -} - fn encode_request(value: &DeliveryRequest) -> Result<Vec<u8>, Error> { let mut bytes = vec![1]; put_str(&mut bytes, value.request_id().as_str())?; diff --git a/crates/sync/src/ingest.rs b/crates/sync/src/ingest.rs @@ -181,9 +181,7 @@ impl Engine { .commit(request) .await .map_err(map_storage_error)?; - let AtomicCommitOutcome::Ingested { admission, .. } = receipt.outcome() else { - return Err(Error::InvalidIngestReceipt); - }; + let AtomicCommitOutcome::Ingested { admission, .. } = receipt.outcome(); if admission.event_id() != observed.event().id() { return Err(Error::InvalidIngestReceipt); } diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -1,5 +1,7 @@ //! Executor-neutral local-first synchronization orchestration. +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + mod engine; pub mod ingest; @@ -13,7 +15,7 @@ pub use engine::Engine; pub use policy::Error; pub use pull::{PullReceipt, PullRequest}; pub use push::{ - AdmissionRunReceipt, DeliveryExecutionReceipt, PushPreparation, PushReceipt, PushRequest, - PushStatus, SigningRunReceipt, + AdmissionRunReceipt, DeliveryExecutionReceipt, PushPreparation, PushRequest, PushStatus, + SigningRunReceipt, }; pub use status::SyncStatus; diff --git a/crates/sync/src/projection.rs b/crates/sync/src/projection.rs @@ -557,3 +557,72 @@ fn map_storage_error(error: StorageError) -> Error { _ => Error::StorageFailed, } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn raw_source_identity_stage_encoding_and_error_mapping_are_exact() { + let invalidation = ProjectionInvalidation::new( + ProjectionId::parse("projection-helper").unwrap(), + ProjectionGeneration::new([1; 32]).unwrap(), + ProjectionGeneration::new([2; 32]).unwrap(), + InvalidationReason::ProjectionGenerationChanged, + 1, + ) + .unwrap(); + let source_generation = SourceGeneration::new([11; 32]).unwrap(); + let digest = RawSourceDigest::new([12; 32]); + let ticket = RebuildTicket::requested( + RebuildTicketId::new([13; 16]).unwrap(), + invalidation, + source_generation, + None, + digest, + ) + .unwrap(); + let matching = RawSourceSnapshot { + generation: source_generation, + high_water: None, + digest, + }; + assert!(matching.matches_ticket(&ticket)); + assert!( + !RawSourceSnapshot { + generation: SourceGeneration::new([14; 32]).unwrap(), + ..matching + } + .matches_ticket(&ticket) + ); + assert!( + !RawSourceSnapshot { + high_water: Some(EventPosition::new( + source_generation, + radroots_storage::event::EventSequence::new(1).unwrap(), + )), + ..matching + } + .matches_ticket(&ticket) + ); + assert!( + !RawSourceSnapshot { + digest: RawSourceDigest::new([15; 32]), + ..matching + } + .matches_ticket(&ticket) + ); + assert_eq!(admission_stage_byte(AdmissionStage::Raw), 0); + assert_eq!(admission_stage_byte(AdmissionStage::Verified), 1); + assert_eq!(admission_stage_byte(AdmissionStage::Visible), 2); + assert_eq!( + map_storage_error(StorageError::ProjectionRevisionConflict), + Error::StorageConflict + ); + assert_eq!( + map_storage_error(StorageError::BackendUnavailable), + Error::StorageFailed + ); + } +} diff --git a/crates/sync/src/push.rs b/crates/sync/src/push.rs @@ -13,11 +13,7 @@ use radroots_signing::{ request::{CancellationPolicy, SignPolicy}, }; use radroots_storage::{ - Journal, Outbox, - atomic::{ - AtomicCommit, AtomicCommitDigest, AtomicCommitDisposition, AtomicCommitId, - AtomicCommitOutcome, AtomicWorkflow, CommitEnqueued, CommitSigned, - }, + atomic::{AtomicCommitDigest, AtomicCommitDisposition}, authored::{ AdmissionState, AuthoredArtifact, AuthoredArtifactId, AuthoredOperation, FailureClass, OperationSettlement, RetrySchedule, SigningState, WorkClaim, WorkFailure, WorkPhase, @@ -33,20 +29,12 @@ use radroots_storage::{ DELIVERY_PLAN_ATTEMPTS_MAX, DeliveryAttemptOutcome, }, event::{AdmissionDisposition, EventAdmission, EventStore}, - journal::{ - IdempotencyDigest, IdempotencyKey, JournalStage, JournalState, OperationInstanceId, - PrepareOperation, - }, - outbox::{ - ClaimOutboxItems, DeliveryAttempt, DeliveryAttemptEvidence, DeliveryPlanDigest, - EnqueueOutboxItem, LeaseId, LeaseOwner, OUTBOX_CLAIM_LIMIT_MAX, OutboxItemId, OutboxRecord, - }, + journal::{IdempotencyKey, OperationInstanceId}, }; use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, SinkFailure, Target, TransportId, - outcome::{DeliveryOutcome, DeliveryOutcomeKind, Retryability}, + DeliveryRequest, SinkFailure, Target, TransportId, + outcome::Retryability, policy::{SatisfactionPolicy, SatisfactionState}, - sink::{DeliveryPayload, DeliveryTargetReceipt}, source::{EventProvenance, ObservedEvent}, target::TargetSet, }; @@ -58,7 +46,6 @@ use crate::{ policy::{Error, OperationKind, SyncId}, }; -const MAX_DELIVERY_LEASE_MS: u64 = 86_400_000; const SIGNING_CLAIM_OWNER_EXACT: &str = "radroots-sync-signing-exact"; const SIGNING_CLAIM_OWNER_LOCAL: &str = "radroots-sync-signing-local"; const SIGNING_CLAIM_OWNER_NON_REPLAYABLE: &str = "radroots-sync-signing-non-replayable"; @@ -247,80 +234,6 @@ impl AdmissionRunReceipt { } } -/// Durable result after signing and atomic outbox enqueue. -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PushReceipt { - operation_id: SyncId, - outbox: OutboxRecord, - replay: bool, -} - -impl PushReceipt { - pub const fn operation_id(&self) -> SyncId { - self.operation_id - } - pub const fn outbox(&self) -> &OutboxRecord { - &self.outbox - } - pub const fn is_replay(&self) -> bool { - self.replay - } -} - -/// Bounds and lease authority for one explicit outbox delivery pass. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DeliveryRunRequest { - owner: LeaseOwner, - lease_seed: SyncId, - lease_duration_ms: u64, - limit: u16, -} - -impl DeliveryRunRequest { - pub fn new( - owner: LeaseOwner, - lease_seed: SyncId, - lease_duration_ms: u64, - limit: u16, - ) -> Result<Self, Error> { - if lease_duration_ms == 0 - || lease_duration_ms > MAX_DELIVERY_LEASE_MS - || limit == 0 - || limit > OUTBOX_CLAIM_LIMIT_MAX - { - return Err(Error::InvalidDeliveryRequest); - } - Ok(Self { - owner, - lease_seed, - lease_duration_ms, - limit, - }) - } -} - -/// Independent durable outcomes from one bounded delivery pass. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DeliveryRunReceipt { - outcomes: Vec<Result<OutboxRecord, Error>>, -} - -impl DeliveryRunReceipt { - pub fn outcomes(&self) -> &[Result<OutboxRecord, Error>] { - self.outcomes.as_slice() - } - pub fn succeeded(&self) -> usize { - self.outcomes - .iter() - .filter(|outcome| outcome.is_ok()) - .count() - } - pub fn failed(&self) -> usize { - self.outcomes.len() - self.succeeded() - } -} - impl Engine { /// Atomically persists the complete parent, exact plan, and delivery intent. /// @@ -478,7 +391,7 @@ impl Engine { None, ) .map_err(map_storage_error)?; - self.apply_artifact_failure(claimed.artifact_id(), fence, failure, None) + self.apply_artifact_failure(claimed.artifact_id(), fence, failure, None, now) .await?; return Err(Error::SigningIndeterminate); } @@ -585,8 +498,14 @@ impl Engine { WorkFailure::new(error.code(), WorkPhase::Signing, class, retry_at, None) .map_err(map_storage_error)?; let retry = retry_schedule(claimed.signing_retry(), &failure, retry_at)?; - self.apply_artifact_failure(claimed.artifact_id(), fence, failure, retry) - .await?; + self.apply_artifact_failure( + claimed.artifact_id(), + fence, + failure, + retry, + applied_at, + ) + .await?; match disposition { RecoveryDisposition::Indeterminate => Err(Error::SigningIndeterminate), RecoveryDisposition::RetryExactRequest @@ -657,7 +576,7 @@ impl Engine { None, ) .map_err(map_storage_error)?; - self.apply_artifact_failure(claimed.artifact_id(), fence, failure, None) + self.apply_artifact_failure(claimed.artifact_id(), fence, failure, None, now) .await?; return Err(error); } @@ -665,7 +584,7 @@ impl Engine { let admission_receipt = match EventStore::admit(self.storage.as_ref(), admission).await { Ok(receipt) => receipt, Err(error) => { - let applied_at = self.clock.now_unix_ms()?; + let applied_at = self.clock.now_unix_ms()?.max(claimed.updated_at_unix_ms()); let terminal = matches!( error, radroots_storage::Error::EventConflict @@ -697,8 +616,14 @@ impl Engine { ) .map_err(map_storage_error)?; let retry = retry_schedule(claimed.admission_retry(), &failure, retry_at)?; - self.apply_artifact_failure(claimed.artifact_id(), fence, failure, retry) - .await?; + self.apply_artifact_failure( + claimed.artifact_id(), + fence, + failure, + retry, + applied_at, + ) + .await?; return Err(Error::AdmissionFailed); } }; @@ -911,8 +836,8 @@ impl Engine { fence: WorkFence, failure: WorkFailure, retry: Option<RetrySchedule>, + applied_at: u64, ) -> Result<AuthoredArtifact, Error> { - let applied_at = self.clock.now_unix_ms()?; let command = AuthoredAtomicCommand::ApplyFailure( ApplyWorkFailure::new( AuthoredWorkTarget::Artifact(artifact_id), @@ -933,263 +858,6 @@ impl Engine { }; Ok(artifact.clone()) } - - /// Authorizes, signs, verifies, and durably enqueues one outbound event. - /// - /// Dropping the future before the final atomic enqueue leaves either a - /// prepared or signed recoverable journal record. Once that commit returns, - /// cancellation cannot claim rollback; replay returns the durable outbox. - pub async fn sign_and_enqueue(&self, request: PushRequest) -> Result<PushReceipt, Error> { - let instance_id = OperationInstanceId::new(*request.operation_id.as_bytes()) - .map_err(map_storage_error)?; - let item_id = - OutboxItemId::new(*request.operation_id.as_bytes()).map_err(map_storage_error)?; - let input_digest = push_input_digest(&request)?; - - if let Some(existing) = Journal::operation(self.storage.as_ref(), instance_id) - .await - .map_err(map_storage_error)? - { - if [ - existing.operation_id() != OperationId::SyncPush, - existing.idempotency_key() != request.idempotency_key(), - existing.input_digest() != input_digest, - ] - .contains(&true) - { - return Err(Error::StorageConflict); - } - if existing.state().stage() == JournalStage::Committed { - let outbox = Outbox::item(self.storage.as_ref(), item_id) - .await - .map_err(map_storage_error)? - .ok_or(Error::StorageFailed)?; - return Ok(PushReceipt { - operation_id: request.operation_id, - outbox, - replay: true, - }); - } - } - - let signed = self.sign_prepared(request.clone()).await?; - self.admit_signed(request.operation_id).await?; - let event = signed - .artifact() - .signed() - .ok_or(Error::InvalidSignerOutput)? - .event() - .clone(); - - let prepared_at = self.clock.now_unix_ms()?; - let prepare = PrepareOperation::new( - instance_id, - OperationId::SyncPush, - request.idempotency_key.clone(), - input_digest, - prepared_at, - ) - .map_err(map_storage_error)?; - let prepare_commit = AtomicCommit::new( - next_commit_id(self, OperationKind::Sign)?, - AtomicCommitDigest::new(*input_digest.as_bytes()), - prepared_at, - AtomicWorkflow::Prepared(prepare), - ) - .map_err(map_storage_error)?; - let prepared_receipt = self - .storage - .commit(prepare_commit) - .await - .map_err(map_storage_error)?; - let AtomicCommitOutcome::Prepared { journal: prepared } = prepared_receipt.outcome() else { - return Err(Error::StorageFailed); - }; - - let signed_record = if prepared.state().stage() == JournalStage::Signed { - if !matches!( - prepared.state(), - JournalState::Signed { event_id } if event_id == event.id() - ) { - return Err(Error::InvalidSignerOutput); - } - prepared.clone() - } else { - let signed_commit = AtomicCommit::new( - next_commit_id(self, OperationKind::Sign)?, - atomic_digest(b"radroots.sync.signed.v1", event.raw_json().as_bytes()), - self.clock.now_unix_ms()?, - AtomicWorkflow::Signed(Box::new(CommitSigned::new( - instance_id, - prepared.revision(), - event.clone(), - ))), - ) - .map_err(map_storage_error)?; - let receipt = self - .storage - .commit(signed_commit) - .await - .map_err(map_storage_error)?; - let AtomicCommitOutcome::Signed { journal, .. } = receipt.outcome() else { - return Err(Error::StorageFailed); - }; - journal.clone() - }; - - let admission = outbound_admission(&event, self.clock.now_unix_ms()?)?; - let delivery = DeliveryRequest::new( - delivery_request_id(request.operation_id), - DeliveryPayload::new(event), - request.targets, - request.satisfaction, - request.delivery_deadline_unix_ms, - ) - .map_err(|_| Error::InvalidPushRequest)?; - let plan_digest = delivery_plan_digest(&delivery); - let committed_at = self.clock.now_unix_ms()?; - let outbox = - EnqueueOutboxItem::new(item_id, instance_id, plan_digest, delivery, committed_at) - .map_err(map_storage_error)?; - let enqueued = CommitEnqueued::new( - instance_id, - signed_record.revision(), - admission, - outbox, - committed_at, - ) - .map_err(map_storage_error)?; - let receipt = self - .storage - .commit( - AtomicCommit::new( - next_commit_id(self, OperationKind::Sign)?, - AtomicCommitDigest::new(*plan_digest.as_bytes()), - committed_at, - AtomicWorkflow::Enqueued(Box::new(enqueued)), - ) - .map_err(map_storage_error)?, - ) - .await - .map_err(map_storage_error)?; - let AtomicCommitOutcome::Enqueued { outbox, .. } = receipt.outcome() else { - return Err(Error::StorageFailed); - }; - Ok(PushReceipt { - operation_id: request.operation_id, - outbox: (**outbox).clone(), - replay: false, - }) - } - - /// Claims and delivers at most the caller-bounded number of outbox items. - pub async fn deliver_pending( - &self, - request: DeliveryRunRequest, - ) -> Result<DeliveryRunReceipt, Error> { - if request.lease_duration_ms > self.deadlines.timeout_ms(OperationKind::Deliver) { - return Err(Error::InvalidDeliveryRequest); - } - let sink = self.sink.as_deref().ok_or(Error::MissingSink)?; - let now = self.clock.now_unix_ms()?; - let expires = now - .checked_add(request.lease_duration_ms) - .ok_or(Error::DeadlineOverflow)?; - let claimed = Outbox::claim( - self.storage.as_ref(), - ClaimOutboxItems::new( - request.owner, - LeaseId::new(*request.lease_seed.as_bytes()).map_err(map_storage_error)?, - now, - expires, - request.limit, - ) - .map_err(map_storage_error)?, - ) - .await - .map_err(map_storage_error)?; - let mut outcomes = Vec::with_capacity(claimed.len()); - for item in claimed { - let delivery_request = item.record().request().clone(); - let attempted_at = self.clock.now_unix_ms()?; - let receipt = if attempted_at >= delivery_request.deadline_unix_ms() { - synthetic_receipt(&delivery_request, false)? - } else { - match sink.deliver(delivery_request.clone()).await { - Ok(receipt) => receipt, - Err(_) => synthetic_receipt(&delivery_request, true)?, - } - }; - if receipt.validate_for_request(&delivery_request).is_err() { - let released_at = self.clock.now_unix_ms()?; - let released = Outbox::release( - self.storage.as_ref(), - item.record().item_id(), - item.lease().id(), - item.record().revision(), - released_at, - None, - ) - .await - .map_err(map_storage_error); - outcomes.push(match released { - Ok(_) => Err(Error::InvalidDeliveryRequest), - Err(error) => Err(error), - }); - continue; - } - let attempt = DeliveryAttempt::new( - item.record() - .last_attempt() - .map_or(1, |attempt| attempt.get().saturating_add(1)), - ) - .map_err(map_storage_error)?; - let evidence = DeliveryAttemptEvidence::new( - item.record().item_id(), - item.lease().id(), - item.record().revision(), - attempt, - receipt, - attempted_at, - ) - .map_err(map_storage_error)?; - let digest = delivery_evidence_digest(&evidence); - let commit = AtomicCommit::new( - next_commit_id(self, OperationKind::Deliver)?, - digest, - attempted_at, - AtomicWorkflow::Delivered(Box::new(evidence)), - ) - .map_err(map_storage_error)?; - let outcome = match self.storage.commit(commit).await { - Ok(receipt) => match receipt.outcome() { - AtomicCommitOutcome::Delivered { outbox } => Ok((**outbox).clone()), - _ => Err(Error::StorageFailed), - }, - Err(error) => Err(map_storage_error(error)), - }; - outcomes.push(outcome); - } - Ok(DeliveryRunReceipt { outcomes }) - } -} - -fn synthetic_receipt(request: &DeliveryRequest, retryable: bool) -> Result<DeliveryReceipt, Error> { - let outcome = if retryable { - DeliveryOutcome::unavailable() - } else { - DeliveryOutcome::failed(Retryability::Terminal) - .map_err(|_| Error::InvalidDeliveryRequest)? - }; - let targets = request - .target_set() - .targets() - .iter() - .cloned() - .map(|target| DeliveryTargetReceipt::skipped(target, outcome.clone())) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| Error::InvalidDeliveryRequest)?; - DeliveryReceipt::for_request(request, targets).map_err(|_| Error::InvalidDeliveryRequest) } fn signer_replay_capability( @@ -1317,48 +985,6 @@ fn delivery_retry_schedule( .map_err(map_storage_error) } -fn delivery_evidence_digest(evidence: &DeliveryAttemptEvidence) -> AtomicCommitDigest { - let mut hasher = Sha256::new(); - hash_field(&mut hasher, b"radroots.sync.delivery-evidence.v1"); - hash_field(&mut hasher, evidence.item_id().as_bytes()); - hash_field(&mut hasher, evidence.lease_id().as_bytes()); - hasher.update(evidence.expected_revision().get().to_be_bytes()); - hasher.update(evidence.attempt().get().to_be_bytes()); - hasher.update(evidence.recorded_at_unix_ms().to_be_bytes()); - hash_field( - &mut hasher, - evidence.receipt().request_id().as_str().as_bytes(), - ); - for target in evidence.receipt().target_receipts() { - hash_field( - &mut hasher, - target.target().fingerprint().as_str().as_bytes(), - ); - hasher.update([u8::from(target.was_attempted())]); - hasher.update([match target.outcome().kind() { - DeliveryOutcomeKind::Accepted => 0, - DeliveryOutcomeKind::Delivered => 1, - DeliveryOutcomeKind::Rejected => 2, - DeliveryOutcomeKind::Unavailable => 3, - DeliveryOutcomeKind::Failed => 4, - }]); - hasher.update([match target.outcome().retryability() { - Retryability::NotApplicable => 0, - Retryability::Retryable => 1, - Retryability::Terminal => 2, - }]); - hash_field( - &mut hasher, - target.outcome().code().unwrap_or_default().as_bytes(), - ); - hash_field( - &mut hasher, - target.outcome().message().unwrap_or_default().as_bytes(), - ); - } - AtomicCommitDigest::new(hasher.finalize().into()) -} - fn outbound_admission( event: &radroots_event::SignedEvent, observed_at_unix_ms: u64, @@ -1415,12 +1041,6 @@ fn outbound_admission( .map_err(map_storage_error) } -fn push_input_digest(request: &PushRequest) -> Result<IdempotencyDigest, Error> { - Ok(IdempotencyDigest::new( - *authored_push_input_digest(request)?.as_bytes(), - )) -} - fn authored_push_input_digest(request: &PushRequest) -> Result<AtomicCommitDigest, Error> { let mut hasher = Sha256::new(); hash_field(&mut hasher, b"radroots.sync.authored-push.v2"); @@ -1456,17 +1076,6 @@ fn authored_push_input_digest(request: &PushRequest) -> Result<AtomicCommitDiges Ok(AtomicCommitDigest::new(hasher.finalize().into())) } -fn delivery_plan_digest(request: &DeliveryRequest) -> DeliveryPlanDigest { - let mut hasher = Sha256::new(); - hash_field(&mut hasher, b"radroots.sync.delivery.v1"); - hash_field(&mut hasher, request.payload().event().raw_json().as_bytes()); - for target in request.target_set().targets() { - hash_field(&mut hasher, target.fingerprint().as_str().as_bytes()); - } - hash_satisfaction(&mut hasher, request.satisfaction()); - DeliveryPlanDigest::new(hasher.finalize().into()) -} - fn hash_satisfaction(hasher: &mut Sha256, policy: &SatisfactionPolicy) { hasher.update([match policy.class() { radroots_transport::policy::SatisfactionClass::Accepted => 0, @@ -1488,13 +1097,6 @@ fn hash_satisfaction(hasher: &mut Sha256, policy: &SatisfactionPolicy) { } } -fn atomic_digest(domain: &[u8], input: &[u8]) -> AtomicCommitDigest { - let mut hasher = Sha256::new(); - hash_field(&mut hasher, domain); - hash_field(&mut hasher, input); - AtomicCommitDigest::new(hasher.finalize().into()) -} - fn authored_ids( operation_id: SyncId, ) -> Result< @@ -1535,10 +1137,6 @@ fn hash_field(hasher: &mut Sha256, value: &[u8]) { hasher.update(value); } -fn next_commit_id(engine: &Engine, operation: OperationKind) -> Result<AtomicCommitId, Error> { - AtomicCommitId::new(*engine.ids.next_id(operation)?.as_bytes()).map_err(map_storage_error) -} - fn delivery_request_id(id: SyncId) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut value = String::from("push-"); @@ -1569,3 +1167,368 @@ fn map_claim_error(error: radroots_storage::Error) -> Error { other => map_storage_error(other), } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use radroots_event::{GenericEventDraft, contract::AuthorRole}; + use radroots_signing::{ + SignerStatus, + actor::ActorSource, + capability::{CancellationSupport, SignerCapability, SignerKind}, + status::SignerAvailability, + }; + use radroots_transport::{ + DeliveryReceipt, + outcome::DeliveryOutcome, + policy::{SatisfactionClass, TargetPolicy}, + sink::{DeliveryPayload, DeliveryTargetReceipt}, + }; + + const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const OTHER_AUTHOR: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + fn request_with( + target_policy: TargetPolicy, + deadline: u64, + cancellation: CancellationPolicy, + ) -> PushRequest { + let plan = AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + 1_800_000_100, + Vec::new(), + "push helper coverage", + AUTHOR, + ) + .unwrap(), + ) + .unwrap(); + let actor = + Actor::from_public_key_hex(AUTHOR, ActorSource::ExplicitPublicKey, [AuthorRole::Any]) + .unwrap(); + PushRequest::new( + SyncId::new([31; 16]).unwrap(), + IdempotencyKey::parse("push-helper-coverage").unwrap(), + actor, + plan, + TargetSet::new(vec![Target::nostr_relay("wss://helper.example").unwrap()]).unwrap(), + SatisfactionPolicy::new(SatisfactionClass::Accepted, target_policy), + deadline, + cancellation, + ) + .unwrap() + } + + fn delivery_plan(request: &PushRequest) -> AuthoredDeliveryPlan { + let (_, artifact_id, plan_id) = authored_ids(request.operation_id()).unwrap(); + let delivery_request = DeliveryRequest::new( + delivery_request_id(request.operation_id()), + DeliveryPayload::new( + radroots_event_codec::Codec::decode_signed_event( + r#"{"id":"762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1800000100,"kind":0,"tags":[],"content":"{\"display_name\":\"Moss Street Farm\",\"bot\":false,\"website\":\"https://mossstreet.example\",\"picture\":42}","sig":"4290da0bb6422986647bc8cd5f63bd52d49f41e7b665d3b47105b8109183e8d596f322c531d4061df53e1d2b70fda12d5d1c14f3720d7a56d9d0a03746af5109"}"#, + ) + .unwrap(), + ), + request.targets().clone(), + request.satisfaction().clone(), + request.delivery_deadline_unix_ms(), + ) + .unwrap(); + AuthoredDeliveryPlan::new_bound(plan_id, artifact_id, delivery_request, 10).unwrap() + } + + fn capability(replay: ReplayCapability) -> SignerCapability { + SignerCapability::new( + SignerKind::Local, + replay, + CancellationSupport::BeforePublication, + false, + false, + ) + } + + #[test] + fn push_request_validates_every_binding_and_exposes_redacted_accessors() { + let request = request_with( + TargetPolicy::any(), + 1_800_000_300_000, + CancellationPolicy::LocalCooperative, + ); + assert_eq!(request.idempotency_key().as_str(), "push-helper-coverage"); + assert_eq!(request.actor().public_key(), *request.plan().author()); + assert_eq!(request.targets().len(), 1); + assert!(request.satisfaction().targets().is_any()); + assert_eq!(request.delivery_deadline_unix_ms(), 1_800_000_300_000); + assert_eq!(request.cancellation(), CancellationPolicy::LocalCooperative); + let debug = format!("{request:?}"); + assert!(debug.contains("[redacted exact authored plan]")); + assert!(!debug.contains("push helper coverage")); + + let invalid_policy = TargetPolicy::required(vec![ + Target::nostr_relay("wss://absent.example") + .unwrap() + .fingerprint() + .clone(), + ]) + .unwrap(); + let mut invalid = request.clone(); + invalid.satisfaction = SatisfactionPolicy::new(SatisfactionClass::Accepted, invalid_policy); + assert!(matches!( + PushRequest::new( + invalid.operation_id, + invalid.idempotency_key, + invalid.actor, + invalid.plan, + invalid.targets, + invalid.satisfaction, + invalid.delivery_deadline_unix_ms, + invalid.cancellation, + ), + Err(Error::InvalidPushRequest) + )); + + let valid = request_with( + TargetPolicy::any(), + 1_800_000_300_000, + CancellationPolicy::PreservePublishedRequest, + ); + assert!(matches!( + PushRequest::new( + valid.operation_id, + valid.idempotency_key.clone(), + valid.actor.clone(), + valid.plan.clone(), + valid.targets.clone(), + valid.satisfaction.clone(), + 0, + valid.cancellation, + ), + Err(Error::InvalidPushRequest) + )); + let wrong_actor = Actor::from_public_key_hex( + OTHER_AUTHOR, + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .unwrap(); + assert!(matches!( + PushRequest::new( + valid.operation_id, + valid.idempotency_key, + wrong_actor, + valid.plan, + valid.targets, + valid.satisfaction, + valid.delivery_deadline_unix_ms, + valid.cancellation, + ), + Err(Error::InvalidPushRequest) + )); + } + + #[test] + fn signer_and_retry_helpers_cover_every_stable_policy_variant() { + let empty = SignerStatus::new(SignerAvailability::Ready, Vec::new(), None); + assert_eq!( + signer_replay_capability(&empty), + Err(Error::SignerCapabilityUnavailable) + ); + let exact = SignerStatus::new( + SignerAvailability::Ready, + vec![capability(ReplayCapability::ExactReplayByRequestId)], + None, + ); + assert_eq!( + signer_replay_capability(&exact).unwrap(), + ReplayCapability::ExactReplayByRequestId + ); + let mixed = SignerStatus::new( + SignerAvailability::Ready, + vec![ + capability(ReplayCapability::ExactReplayByRequestId), + capability(ReplayCapability::LocalReplaySafe), + ], + None, + ); + assert_eq!( + signer_replay_capability(&mixed).unwrap(), + ReplayCapability::NonReplayable + ); + assert_eq!( + signing_claim_owner(ReplayCapability::ExactReplayByRequestId), + SIGNING_CLAIM_OWNER_EXACT + ); + assert_eq!( + signing_claim_owner(ReplayCapability::LocalReplaySafe), + SIGNING_CLAIM_OWNER_LOCAL + ); + assert_eq!( + signing_claim_owner(ReplayCapability::NonReplayable), + SIGNING_CLAIM_OWNER_NON_REPLAYABLE + ); + + let failure = WorkFailure::new( + "retry", + WorkPhase::Signing, + FailureClass::Retryable, + Some(20), + None, + ) + .unwrap(); + assert!(retry_schedule(None, &failure, None).unwrap().is_none()); + let first = retry_schedule(None, &failure, Some(20)).unwrap().unwrap(); + assert_eq!(first.attempt(), NonZeroU32::MIN); + let next_failure = WorkFailure::new( + "retry", + WorkPhase::Signing, + FailureClass::Retryable, + Some(21), + None, + ) + .unwrap(); + assert_eq!( + retry_schedule(Some(&first), &next_failure, Some(21)) + .unwrap() + .unwrap() + .attempt() + .get(), + 2 + ); + } + + #[test] + fn delivery_failure_and_retry_helpers_normalize_all_outcome_classes() { + let request = request_with( + TargetPolicy::any(), + 1_800_000_300_000, + CancellationPolicy::PreservePublishedRequest, + ); + let plan = delivery_plan(&request); + let delivery_request = plan.request().unwrap(); + let terminal = SinkFailure::for_request( + delivery_request, + "terminal", + Retryability::Terminal, + None, + None, + Vec::new(), + ) + .unwrap(); + assert_eq!( + normalize_sink_failure(delivery_request, terminal.clone(), 100).unwrap(), + terminal + ); + let retryable = SinkFailure::for_request( + delivery_request, + "retryable", + Retryability::Retryable, + None, + Some("retry".to_owned()), + Vec::new(), + ) + .unwrap(); + let normalized = normalize_sink_failure(delivery_request, retryable, 100).unwrap(); + assert_eq!(normalized.retry_after_unix_ms(), Some(1_100)); + assert_eq!( + normalize_sink_failure(delivery_request, normalized.clone(), 100).unwrap(), + normalized + ); + let past = SinkFailure::for_request( + delivery_request, + "past", + Retryability::Retryable, + Some(99), + None, + Vec::new(), + ) + .unwrap(); + assert_eq!( + normalize_sink_failure(delivery_request, past, 100) + .unwrap() + .retry_after_unix_ms(), + Some(101) + ); + + let receipt = DeliveryReceipt::for_request( + delivery_request, + delivery_request + .target_set() + .targets() + .iter() + .cloned() + .map(|target| { + DeliveryTargetReceipt::attempted(target, DeliveryOutcome::unavailable()) + }) + .collect(), + ) + .unwrap(); + let receipt_outcome = DeliveryAttemptOutcome::Receipt(receipt); + assert!( + delivery_retry_schedule(&plan, &receipt_outcome, SatisfactionState::Satisfied, 100) + .unwrap() + .is_none() + ); + let schedule = + delivery_retry_schedule(&plan, &receipt_outcome, SatisfactionState::Pending, 100) + .unwrap() + .unwrap(); + assert_eq!(schedule.not_before_unix_ms(), 1_100); + let retry_outcome = DeliveryAttemptOutcome::SinkFailure(normalized); + assert!( + delivery_retry_schedule(&plan, &retry_outcome, SatisfactionState::Pending, 100) + .unwrap() + .is_some() + ); + let terminal_outcome = DeliveryAttemptOutcome::SinkFailure(terminal); + assert!( + delivery_retry_schedule(&plan, &terminal_outcome, SatisfactionState::Pending, 100) + .unwrap() + .is_none() + ); + + for policy in [ + TargetPolicy::any(), + TargetPolicy::all(), + TargetPolicy::quorum(1).unwrap(), + TargetPolicy::required(vec![request.targets().targets()[0].fingerprint().clone()]) + .unwrap(), + ] { + let request = request_with( + policy, + 1_800_000_300_000, + CancellationPolicy::LocalCooperative, + ); + assert_ne!( + authored_push_input_digest(&request).unwrap().as_bytes(), + &[0; 32] + ); + } + } + + #[test] + fn storage_error_maps_are_explicit_and_fail_closed() { + assert_eq!( + map_storage_error(radroots_storage::Error::AtomicCommitConflict), + Error::StorageConflict + ); + assert_eq!( + map_storage_error(radroots_storage::Error::BackendUnavailable), + Error::StorageFailed + ); + assert_eq!( + map_claim_error(radroots_storage::Error::DeliveryPlanClaimConflict), + Error::WorkClaimConflict + ); + assert_eq!( + map_claim_error(radroots_storage::Error::BackendUnavailable), + Error::StorageFailed + ); + assert_eq!( + delivery_request_id(SyncId::new([0xab; 16]).unwrap()).len(), + 37 + ); + } +} diff --git a/crates/sync/src/status.rs b/crates/sync/src/status.rs @@ -9,7 +9,8 @@ use radroots_protocol::runtime::v1::{ use radroots_signing::{SignerStatus, status::SignerAvailability}; use radroots_storage::{ EventStore, Outbox, ProjectionStore, - outbox::{OutboxRecord, OutboxStage, OutboxStatus}, + authored_delivery::{AuthoredDeliveryPlan, AuthoredDeliveryState}, + outbox::OutboxStatus, projection::{ProjectionHealth, ProjectionId, ProjectionStatus}, status::{ EventStoreHealth, EventStoreStatus, IntegrityHealth, ShutdownState, StorageStatus, @@ -215,31 +216,35 @@ impl Engine { /// Classifies host action for one durable plan without mutating it. pub fn retry_decision( &self, - record: &OutboxRecord, + plan: &AuthoredDeliveryPlan, now_unix_ms: u64, ) -> Result<SyncRetryDecision, Error> { if now_unix_ms == 0 { return Err(Error::ClockUnavailable); } - match record.stage() { - OutboxStage::Satisfied => return Ok(SyncRetryDecision::Satisfied), - OutboxStage::Exhausted => return Ok(SyncRetryDecision::Exhausted), - OutboxStage::Pending | OutboxStage::Leased | OutboxStage::Retryable => {} + match plan.state() { + AuthoredDeliveryState::Satisfied => return Ok(SyncRetryDecision::Satisfied), + AuthoredDeliveryState::Exhausted + | AuthoredDeliveryState::FailedTerminal + | AuthoredDeliveryState::Cancelled => return Ok(SyncRetryDecision::Exhausted), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable => {} } - if now_unix_ms >= record.request().deadline_unix_ms() { + if now_unix_ms >= plan.intent().deadline_unix_ms() { return Ok(SyncRetryDecision::Expired); } - if let Some(lease) = record.lease() - && lease.is_active_at(now_unix_ms) + if let Some(claim) = plan.claim_evidence() + && now_unix_ms < claim.expires_at_unix_ms() { return Ok(SyncRetryDecision::InFlightUntil { - unix_ms: lease.expires_at_unix_ms(), + unix_ms: claim.expires_at_unix_ms(), }); } - if let Some(unix_ms) = record.retry_not_before_unix_ms() - && now_unix_ms < unix_ms + if let Some(retry) = plan.retry() + && now_unix_ms < retry.not_before_unix_ms() { - return Ok(SyncRetryDecision::DeferredUntil { unix_ms }); + return Ok(SyncRetryDecision::DeferredUntil { + unix_ms: retry.not_before_unix_ms(), + }); } Ok(SyncRetryDecision::Ready) } @@ -370,3 +375,128 @@ const fn storage_state( SyncCapabilityState::Degraded } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use radroots_storage::{ + event::SourceGeneration, + status::{EventStoreMode, IntegrityStatus, StorageBackend, StorageOpenMode, WriterPolicy}, + }; + + fn storage(health: IntegrityHealth, shutdown: ShutdownState) -> StorageStatus { + let failed = u32::from(health == IntegrityHealth::Corrupt); + let checked = if health == IntegrityHealth::Unknown { + None + } else { + Some(1) + }; + StorageStatus::new( + StorageBackend::Memory, + StorageOpenMode::ReadWriteExisting, + WriterPolicy::NoWriter, + shutdown, + IntegrityStatus::new(health, checked, 1, failed).unwrap(), + false, + 0, + ) + .unwrap() + } + + fn events(health: EventStoreHealth) -> EventStoreStatus { + EventStoreStatus::new( + SourceGeneration::new([21; 32]).unwrap(), + EventStoreMode::ReadWrite, + health, + 0, + 0, + 0, + ) + .unwrap() + } + + #[test] + fn health_and_protocol_classification_cover_every_state() { + assert_eq!( + availability_state(Availability::Available), + SyncCapabilityState::Available + ); + assert_eq!( + availability_state(Availability::Degraded), + SyncCapabilityState::Degraded + ); + assert_eq!( + availability_state(Availability::Unavailable), + SyncCapabilityState::Configured + ); + let source = CapabilityReport::<SourceStatus>::unsupported(); + let sink = CapabilityReport::<SinkStatus>::unsupported(); + let signer = CapabilityReport::<SignerStatus>::unsupported(); + for (storage, events, expected) in [ + ( + storage(IntegrityHealth::Healthy, ShutdownState::Open), + events(EventStoreHealth::Available), + SyncHealth::Healthy, + ), + ( + storage(IntegrityHealth::Corrupt, ShutdownState::Open), + events(EventStoreHealth::Available), + SyncHealth::Unavailable, + ), + ( + storage(IntegrityHealth::Healthy, ShutdownState::Closing), + events(EventStoreHealth::Available), + SyncHealth::Unavailable, + ), + ( + storage(IntegrityHealth::Healthy, ShutdownState::Open), + events(EventStoreHealth::Unavailable), + SyncHealth::Unavailable, + ), + ( + storage(IntegrityHealth::Degraded, ShutdownState::Open), + events(EventStoreHealth::Available), + SyncHealth::Degraded, + ), + ] { + assert_eq!( + aggregate_health(storage, &events, &source, &sink, &signer, &[]), + expected + ); + } + let compiled = CapabilityReport::<SourceStatus>::compiled(None); + assert_eq!( + aggregate_health( + storage(IntegrityHealth::Healthy, ShutdownState::Open), + &events(EventStoreHealth::Available), + &compiled, + &sink, + &signer, + &[], + ), + SyncHealth::Degraded + ); + assert_eq!( + storage_state( + storage(IntegrityHealth::Healthy, ShutdownState::Open), + EventStoreHealth::Available, + ), + SyncCapabilityState::Available + ); + assert_eq!( + storage_state( + storage(IntegrityHealth::Degraded, ShutdownState::Open), + EventStoreHealth::Degraded, + ), + SyncCapabilityState::Degraded + ); + assert_eq!( + storage_state( + storage(IntegrityHealth::Healthy, ShutdownState::Closed), + EventStoreHealth::Available, + ), + SyncCapabilityState::Configured + ); + } +} diff --git a/crates/sync/tests/engine_composition.rs b/crates/sync/tests/engine_composition.rs @@ -18,6 +18,7 @@ struct MockSource; struct MockSink; struct MockSigner; struct UnconfiguredSource; +struct UnconfiguredSink; struct FixedClock; struct FixedIds; @@ -94,6 +95,28 @@ impl EventSink for MockSink { } } +impl EventSink for UnconfiguredSink { + fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { + Box::pin(async { + Ok(SinkStatus::new( + radroots_transport::TransportId::NOSTR, + false, + Maturity::Preview, + Availability::Available, + SinkCapabilities::DELIVER, + "not configured", + )) + }) + } + + fn deliver( + &self, + _request: DeliveryRequest, + ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { + Box::pin(async { unreachable!("status does not deliver") }) + } +} + impl Signer for MockSigner { fn status( &self, @@ -333,6 +356,15 @@ fn status_aggregates_typed_capability_and_protocol_reports() { assert!(status.source().status().is_some()); assert_eq!(status.sink().state(), SyncCapabilityState::Unsupported); + let (storage, clock, ids, deadlines) = dependencies(); + let compiled_sink = Engine::builder(storage, clock, ids, deadlines) + .sink(Arc::new(UnconfiguredSink)) + .build() + .expect("unconfigured sink engine"); + let status = block_on(compiled_sink.status(&[])).expect("compiled sink status"); + assert_eq!(status.sink().state(), SyncCapabilityState::Compiled); + assert!(status.sink().status().is_some()); + assert_eq!( block_on(full.status(&[projection.clone(), projection])), Err(Error::InvalidStatusRequest) diff --git a/crates/sync/tests/projection.rs b/crates/sync/tests/projection.rs @@ -16,8 +16,8 @@ use radroots_storage::{ event::{EventAdmission, SourceGeneration, StoredVisibleEvent}, memory::MemoryStorage, projection::{ - ProjectionGeneration, ProjectionHealth, ProjectionId, RawSourceDigest, RebuildFailure, - RebuildTicketId, + InvalidationReason, ProjectionGeneration, ProjectionHealth, ProjectionId, + ProjectionInvalidation, RawSourceDigest, RebuildFailure, RebuildTicketId, }, }; use radroots_sync::{ @@ -309,6 +309,40 @@ fn generation_change_rebuilds_and_reducer_failure_is_durable() { } #[test] +fn obsolete_generation_request_fails_closed_after_invalidation() { + let (engine, storage, id) = setup(); + seed(&storage, 1); + let active = reducer(&id, 1, false); + block_on(engine.refresh_projection( + RefreshRequest::new(id.clone(), active.generation(), 10, 1).expect("request"), + &active, + )) + .expect("initial refresh"); + + let replacement = ProjectionGeneration::new([2; 32]).expect("replacement generation"); + block_on(ProjectionStore::invalidate( + &*storage, + ProjectionInvalidation::new( + id.clone(), + active.generation(), + replacement, + InvalidationReason::ProjectionGenerationChanged, + 2_000, + ) + .expect("invalidation"), + )) + .expect("invalidate active generation"); + + assert_eq!( + block_on(engine.refresh_projection( + RefreshRequest::new(id, active.generation(), 10, 1).expect("obsolete request"), + &active, + )), + Err(Error::StorageFailed) + ); +} + +#[test] fn partial_rebuild_resumes_and_rejects_concurrent_generation() { let (engine, storage, id) = setup(); seed(&storage, 2); diff --git a/crates/sync/tests/push_enqueue.rs b/crates/sync/tests/push_enqueue.rs @@ -23,22 +23,23 @@ use radroots_signing::{ status::SignerAvailability, }; use radroots_storage::{ - EventStore, Journal, Outbox, + EventStore, Journal, Outbox, ProjectionStore, + atomic::AtomicStorage, + authored_atomic::AuthoredAtomicStorage, authored_delivery::AuthoredDeliveryState, event::{EventQuery, EventQueryBounds, SourceGeneration}, journal::{IdempotencyKey, OperationInstanceId}, memory::MemoryStorage, - outbox::{ClaimOutboxItems, LeaseId, LeaseOwner, OutboxStage, SatisfactionResult}, + status::StorageStatusProvider, }; use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; use radroots_sync::{ Engine, PushRequest, policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - push::DeliveryRunRequest, }; use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure, SinkStatus, - Target, TargetSet, TransportId, + DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, EventSource, FetchPage, + FetchRequest, SinkFailure, SinkStatus, SourceStatus, Target, TargetSet, TransportId, outcome::{DeliveryOutcome, Retryability}, policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, sink::DeliveryTargetReceipt, @@ -49,6 +50,501 @@ const CONTENT: &str = "frozen-content"; struct MockSink; +struct FaultStorage { + inner: Arc<MemoryStorage>, + fault_kind: AtomicUsize, + remaining: AtomicUsize, + admit_error: AtomicUsize, + prepared: Mutex<Option<radroots_storage::authored_atomic::AuthoredAtomicOutcome>>, +} + +impl FaultStorage { + fn new(generation: u8) -> Self { + Self { + inner: Arc::new(MemoryStorage::new( + SourceGeneration::new([generation; 32]).expect("generation"), + )), + fault_kind: AtomicUsize::new(0), + remaining: AtomicUsize::new(0), + admit_error: AtomicUsize::new(0), + prepared: Mutex::new(None), + } + } + + fn fault_next_prepared(&self) { + self.fault_kind.store(1, Ordering::Relaxed); + self.remaining.store(1, Ordering::Relaxed); + } + + fn fault_nth_artifact(&self, nth: usize) { + self.fault_kind.store(2, Ordering::Relaxed); + self.remaining.store(nth, Ordering::Relaxed); + } + + fn fault_nth_plan(&self, nth: usize) { + self.fault_kind.store(3, Ordering::Relaxed); + self.remaining.store(nth, Ordering::Relaxed); + } + + fn fault_prepared_identity(&self) { + self.fault_kind.store(4, Ordering::Relaxed); + self.remaining.store(1, Ordering::Relaxed); + } + + fn fail_admission_with(&self, value: usize) { + self.admit_error.store(value, Ordering::Relaxed); + } +} + +impl EventStore for FaultStorage { + fn status( + &self, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::status::EventStoreStatus, radroots_storage::Error>, + > { + EventStore::status(self.inner.as_ref()) + } + fn admit( + &self, + value: radroots_storage::event::EventAdmission, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::event::AdmissionReceipt, radroots_storage::Error>, + > { + match self.admit_error.load(Ordering::Relaxed) { + 1 => Box::pin(async { Err(radroots_storage::Error::EventConflict) }), + 2 => Box::pin(async { Err(radroots_storage::Error::BackendUnavailable) }), + _ => EventStore::admit(self.inner.as_ref(), value), + } + } + fn query_raw( + &self, + value: radroots_storage::event::EventQuery, + ) -> radroots_transport::BoxFuture< + '_, + Result< + radroots_storage::event::EventPage<radroots_storage::event::StoredRawEvent>, + radroots_storage::Error, + >, + > { + EventStore::query_raw(self.inner.as_ref(), value) + } + fn query_verified( + &self, + value: radroots_storage::event::EventQuery, + ) -> radroots_transport::BoxFuture< + '_, + Result< + radroots_storage::event::EventPage<radroots_storage::event::StoredVerifiedEvent>, + radroots_storage::Error, + >, + > { + EventStore::query_verified(self.inner.as_ref(), value) + } + fn query_visible( + &self, + value: radroots_storage::event::EventQuery, + ) -> radroots_transport::BoxFuture< + '_, + Result< + radroots_storage::event::EventPage<radroots_storage::event::StoredVisibleEvent>, + radroots_storage::Error, + >, + > { + EventStore::query_visible(self.inner.as_ref(), value) + } + fn query_provenance( + &self, + id: radroots_storage::event::EventId, + bounds: radroots_storage::event::EventQueryBounds, + ) -> radroots_transport::BoxFuture< + '_, + Result< + radroots_storage::event::EventPage<radroots_storage::event::StoredEventProvenance>, + radroots_storage::Error, + >, + > { + EventStore::query_provenance(self.inner.as_ref(), id, bounds) + } +} + +impl Journal for FaultStorage { + fn prepare( + &self, + value: radroots_storage::journal::PrepareOperation, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::journal::PrepareReceipt, radroots_storage::Error>, + > { + Journal::prepare(self.inner.as_ref(), value) + } + fn operation( + &self, + id: OperationInstanceId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::journal::OperationRecord>, radroots_storage::Error>, + > { + Journal::operation(self.inner.as_ref(), id) + } + fn by_idempotency_key( + &self, + id: radroots_storage::journal::OperationId, + key: IdempotencyKey, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::journal::OperationRecord>, radroots_storage::Error>, + > { + Journal::by_idempotency_key(self.inner.as_ref(), id, key) + } + fn transition( + &self, + value: radroots_storage::journal::JournalTransition, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::journal::OperationRecord, radroots_storage::Error>, + > { + Journal::transition(self.inner.as_ref(), value) + } + fn recoverable( + &self, + limit: u16, + ) -> radroots_transport::BoxFuture< + '_, + Result<Vec<radroots_storage::journal::OperationRecord>, radroots_storage::Error>, + > { + Journal::recoverable(self.inner.as_ref(), limit) + } +} + +impl Outbox for FaultStorage { + fn enqueue( + &self, + value: radroots_storage::outbox::EnqueueOutboxItem, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::outbox::EnqueueReceipt, radroots_storage::Error>, + > { + Outbox::enqueue(self.inner.as_ref(), value) + } + fn item( + &self, + id: radroots_storage::outbox::OutboxItemId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::outbox::OutboxRecord>, radroots_storage::Error>, + > { + Outbox::item(self.inner.as_ref(), id) + } + fn claim( + &self, + value: radroots_storage::outbox::ClaimOutboxItems, + ) -> radroots_transport::BoxFuture< + '_, + Result<Vec<radroots_storage::outbox::ClaimedOutboxItem>, radroots_storage::Error>, + > { + Outbox::claim(self.inner.as_ref(), value) + } + fn record_attempt( + &self, + value: radroots_storage::outbox::DeliveryAttemptEvidence, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::outbox::OutboxRecord, radroots_storage::Error>, + > { + Outbox::record_attempt(self.inner.as_ref(), value) + } + fn release( + &self, + id: radroots_storage::outbox::OutboxItemId, + lease: radroots_storage::outbox::LeaseId, + revision: radroots_storage::outbox::OutboxRevision, + at: u64, + retry: Option<u64>, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::outbox::OutboxRecord, radroots_storage::Error>, + > { + Outbox::release(self.inner.as_ref(), id, lease, revision, at, retry) + } + fn status( + &self, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::outbox::OutboxStatus, radroots_storage::Error>, + > { + Outbox::status(self.inner.as_ref()) + } +} + +impl ProjectionStore for FaultStorage { + fn status( + &self, + id: radroots_storage::projection::ProjectionId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>, + > { + ProjectionStore::status(self.inner.as_ref(), id) + } + fn checkpoint( + &self, + value: radroots_storage::projection::ProjectionCheckpoint, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>, + > { + ProjectionStore::checkpoint(self.inner.as_ref(), value) + } + fn invalidate( + &self, + value: radroots_storage::projection::ProjectionInvalidation, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>, + > { + ProjectionStore::invalidate(self.inner.as_ref(), value) + } + fn invalidation( + &self, + id: radroots_storage::projection::ProjectionId, + generation: radroots_storage::projection::ProjectionGeneration, + ) -> radroots_transport::BoxFuture< + '_, + Result< + Option<radroots_storage::projection::ProjectionInvalidation>, + radroots_storage::Error, + >, + > { + ProjectionStore::invalidation(self.inner.as_ref(), id, generation) + } + fn request_rebuild( + &self, + value: radroots_storage::projection::RebuildTicket, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>, + > { + ProjectionStore::request_rebuild(self.inner.as_ref(), value) + } + fn rebuild( + &self, + id: radroots_storage::projection::RebuildTicketId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>, + > { + ProjectionStore::rebuild(self.inner.as_ref(), id) + } + fn transition_rebuild( + &self, + value: radroots_storage::projection::RebuildTransition, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>, + > { + ProjectionStore::transition_rebuild(self.inner.as_ref(), value) + } + fn event_index_manifest( + &self, + generation: radroots_storage::projection::ProjectionGeneration, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>, + > { + ProjectionStore::event_index_manifest(self.inner.as_ref(), generation) + } + fn put_event_index_manifest( + &self, + value: radroots_storage::projection::EventIndexManifest, + ) -> radroots_transport::BoxFuture<'_, Result<(), radroots_storage::Error>> { + ProjectionStore::put_event_index_manifest(self.inner.as_ref(), value) + } + fn event_index_checkpoint( + &self, + generation: radroots_storage::projection::ProjectionGeneration, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>, + > { + ProjectionStore::event_index_checkpoint(self.inner.as_ref(), generation) + } + fn put_event_index_checkpoint( + &self, + value: radroots_storage::projection::EventIndexCheckpoint, + ) -> radroots_transport::BoxFuture<'_, Result<(), radroots_storage::Error>> { + ProjectionStore::put_event_index_checkpoint(self.inner.as_ref(), value) + } +} + +impl AtomicStorage for FaultStorage { + fn commit( + &self, + value: radroots_storage::atomic::AtomicCommit, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::atomic::AtomicCommitReceipt, radroots_storage::Error>, + > { + AtomicStorage::commit(self.inner.as_ref(), value) + } + fn receipt( + &self, + id: radroots_storage::atomic::AtomicCommitId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::atomic::AtomicCommitReceipt>, radroots_storage::Error>, + > { + AtomicStorage::receipt(self.inner.as_ref(), id) + } +} + +impl StorageStatusProvider for FaultStorage { + fn storage_status( + &self, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::status::StorageStatus, radroots_storage::Error>, + > { + StorageStatusProvider::storage_status(self.inner.as_ref()) + } +} + +impl AuthoredAtomicStorage for FaultStorage { + fn execute_authored( + &self, + command: radroots_storage::authored_atomic::AuthoredAtomicCommand, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_storage::authored_atomic::AuthoredAtomicReceipt, radroots_storage::Error>, + > { + Box::pin(async move { + let receipt = + AuthoredAtomicStorage::execute_authored(self.inner.as_ref(), command).await?; + if let radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared { .. } = + receipt.outcome() + { + let mut cache = self.prepared.lock().expect("prepared cache"); + if cache.is_none() { + *cache = Some(receipt.outcome().clone()); + } + } + let kind = match receipt.outcome() { + radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared { .. } => 1, + radroots_storage::authored_atomic::AuthoredAtomicOutcome::Artifact(_) => 2, + radroots_storage::authored_atomic::AuthoredAtomicOutcome::DeliveryPlan(_) => 3, + }; + let fault = self.fault_kind.load(Ordering::Relaxed); + if (fault != kind && !(fault == 4 && kind == 1)) + || self.remaining.fetch_sub(1, Ordering::Relaxed) != 1 + { + return Ok(receipt); + } + let cached = self + .prepared + .lock() + .expect("prepared cache") + .clone() + .expect("prepared outcome"); + let forged = match (fault, cached) { + (4, cached) => cached, + ( + 1, + radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared { + artifacts, + .. + }, + ) => radroots_storage::authored_atomic::AuthoredAtomicOutcome::Artifact( + artifacts[0].clone(), + ), + ( + 2, + radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared { + delivery_plans, + .. + }, + ) => radroots_storage::authored_atomic::AuthoredAtomicOutcome::DeliveryPlan( + delivery_plans[0].clone(), + ), + ( + 3, + radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared { + artifacts, + .. + }, + ) => radroots_storage::authored_atomic::AuthoredAtomicOutcome::Artifact( + artifacts[0].clone(), + ), + _ => unreachable!(), + }; + radroots_storage::authored_atomic::AuthoredAtomicReceipt::from_durable_parts( + receipt.commit_id(), + receipt.digest(), + receipt.disposition(), + receipt.committed_at_unix_ms(), + forged, + ) + }) + } + fn authored_receipt( + &self, + id: radroots_storage::atomic::AtomicCommitId, + ) -> radroots_transport::BoxFuture< + '_, + Result< + Option<radroots_storage::authored_atomic::AuthoredAtomicReceipt>, + radroots_storage::Error, + >, + > { + AuthoredAtomicStorage::authored_receipt(self.inner.as_ref(), id) + } + fn authored_operation( + &self, + id: OperationInstanceId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::authored::AuthoredOperation>, radroots_storage::Error>, + > { + AuthoredAtomicStorage::authored_operation(self.inner.as_ref(), id) + } + fn authored_artifact( + &self, + id: radroots_storage::authored::AuthoredArtifactId, + ) -> radroots_transport::BoxFuture< + '_, + Result<Option<radroots_storage::authored::AuthoredArtifact>, radroots_storage::Error>, + > { + AuthoredAtomicStorage::authored_artifact(self.inner.as_ref(), id) + } + fn authored_delivery_plan( + &self, + id: radroots_storage::authored_delivery::AuthoredDeliveryPlanId, + ) -> radroots_transport::BoxFuture< + '_, + Result< + Option<radroots_storage::authored_delivery::AuthoredDeliveryPlan>, + radroots_storage::Error, + >, + > { + AuthoredAtomicStorage::authored_delivery_plan(self.inner.as_ref(), id) + } +} + +struct MockSource; + +impl EventSource for MockSource { + fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> { + Box::pin(async { unreachable!("missing-sink check does not inspect source") }) + } + + fn fetch( + &self, + _request: FetchRequest, + ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> { + Box::pin(async { unreachable!("missing-sink check does not fetch") }) + } +} + impl EventSink for MockSink { fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { Box::pin(async { unreachable!("enqueue does not inspect sink") }) @@ -63,8 +559,9 @@ impl EventSink for MockSink { enum DeliveryBehavior { Outcomes(Vec<DeliveryOutcome>), - AdapterError, MismatchedRequest, + Failure(Retryability), + MismatchedFailure, Pending, } @@ -106,15 +603,6 @@ impl EventSink for ScriptedSink { DeliveryBehavior::Outcomes(outcomes) => { Ok(receipt(&request, outcomes).expect("valid scripted receipt")) } - DeliveryBehavior::AdapterError => Err(SinkFailure::for_request( - &request, - "adapter_unavailable", - Retryability::Retryable, - None, - None, - Vec::new(), - ) - .expect("valid scripted failure")), DeliveryBehavior::MismatchedRequest => { let mismatched = DeliveryRequest::new( "mismatched-request", @@ -130,6 +618,34 @@ impl EventSink for ScriptedSink { ) .expect("mismatched receipt")) } + DeliveryBehavior::Failure(retryability) => Err(SinkFailure::for_request( + &request, + "scripted_failure", + retryability, + None, + Some("scripted failure".to_owned()), + Vec::new(), + ) + .expect("valid scripted failure")), + DeliveryBehavior::MismatchedFailure => { + let mismatched = DeliveryRequest::new( + "mismatched-failure", + request.payload().clone(), + request.target_set().clone(), + request.satisfaction().clone(), + request.deadline_unix_ms(), + ) + .expect("mismatched request"); + Err(SinkFailure::for_request( + &mismatched, + "mismatched_failure", + Retryability::Terminal, + None, + None, + Vec::new(), + ) + .expect("mismatched failure")) + } DeliveryBehavior::Pending => std::future::pending().await, } }) @@ -367,57 +883,27 @@ fn setup_engine_with_sink( ((engine, storage), clock) } -fn delivery_run(seed: u8, limit: u16) -> DeliveryRunRequest { - DeliveryRunRequest::new( - LeaseOwner::parse("sync-delivery-test").expect("lease owner"), - SyncId::new([seed; 16]).expect("lease seed"), - 1_000, - limit, +fn fault_engine( + storage: Arc<FaultStorage>, + signer: Arc<MockSigner>, + sink: Arc<dyn EventSink>, +) -> Engine { + let capability: Arc<dyn SyncStorage> = storage; + Engine::builder( + capability, + Arc::new(TestClock(AtomicU64::new(1_800_000_200_000))), + Arc::new(TestIds(AtomicU64::new(10))), + DeadlinePolicy::new(10_000, 10_000, 10_000).unwrap(), ) - .expect("delivery run") + .sink(sink) + .signer(signer) + .build() + .unwrap() } -#[test] -fn authorized_signing_atomically_enqueues_and_replays_without_resigning() { - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let (engine, storage) = setup_engine(signer.clone()); - let request = request(1, "wss://relay.example"); - assert_eq!(request.operation_id().as_bytes(), &[1; 16]); - assert_eq!(request.idempotency_key().as_str(), "push-1"); - assert_ne!(request.actor().public_key().as_bytes(), &[0; 32]); - assert!(!request.plan().body().content().is_empty()); - assert_eq!(request.targets().len(), 1); - assert_eq!(request.satisfaction().class(), SatisfactionClass::Accepted); - assert_eq!( - request.cancellation(), - CancellationPolicy::PreservePublishedRequest - ); - assert!(format!("{request:?}").contains("redacted exact authored plan")); - let receipt = block_on(engine.sign_and_enqueue(request.clone())).expect("enqueue"); - assert_eq!(receipt.operation_id().as_bytes(), &[1; 16]); - assert!(!receipt.is_replay()); - assert_eq!(receipt.outbox().stage(), OutboxStage::Pending); - assert_eq!(signer.calls.load(Ordering::Relaxed), 1); - let replay = block_on(engine.sign_and_enqueue(request)).expect("replay"); - assert!(replay.is_replay()); - assert_eq!(replay.outbox(), receipt.outbox()); - assert_eq!(signer.calls.load(Ordering::Relaxed), 1); - - let visible = block_on(storage.query_visible(EventQuery::all( - EventQueryBounds::first(10).expect("bounds"), - ))) - .expect("visible events"); - assert_eq!(visible.items().len(), 1); - assert!( - block_on(Outbox::item( - &*storage, - radroots_storage::outbox::OutboxItemId::new([1; 16]).expect("item id"), - )) - .expect("outbox item") - .is_some() - ); +fn execute_to_admitted(engine: &Engine, push: &PushRequest) { + block_on(engine.sign_prepared(push.clone())).expect("sign prepared push"); + block_on(engine.admit_signed(push.operation_id())).expect("admit signed push"); } #[test] @@ -476,6 +962,119 @@ fn preparation_is_atomic_status_visible_and_replays_without_external_effects() { } #[test] +fn authored_storage_outcome_contracts_fail_closed_at_each_orchestration_phase() { + let storage = Arc::new(FaultStorage::new(84)); + storage.fault_next_prepared(); + let engine = fault_engine( + storage, + Arc::new(MockSigner::new(SignBehavior::Pending)), + Arc::new(MockSink), + ); + assert_eq!( + block_on(engine.prepare_push(request(84, "wss://fault.example"))), + Err(Error::StorageFailed) + ); + + let storage = Arc::new(FaultStorage::new(85)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Pending)), + Arc::new(MockSink), + ); + block_on(engine.prepare_push(request(85, "wss://fault.example"))).unwrap(); + storage.fault_prepared_identity(); + assert_eq!( + block_on(engine.prepare_push(request(86, "wss://fault.example"))), + Err(Error::StorageFailed) + ); + + let storage = Arc::new(FaultStorage::new(87)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(87, "wss://fault.example"); + block_on(engine.prepare_push(push.clone())).unwrap(); + storage.fault_nth_artifact(1); + assert_eq!( + block_on(engine.sign_prepared(push)), + Err(Error::StorageFailed) + ); + + let storage = Arc::new(FaultStorage::new(88)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(88, "wss://fault.example"); + block_on(engine.prepare_push(push.clone())).unwrap(); + storage.fault_nth_artifact(2); + assert_eq!( + block_on(engine.sign_prepared(push)), + Err(Error::StorageFailed) + ); + + let storage = Arc::new(FaultStorage::new(89)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(89, "wss://fault.example"); + block_on(engine.sign_prepared(push.clone())).unwrap(); + storage.fault_nth_artifact(2); + assert_eq!( + block_on(engine.admit_signed(push.operation_id())), + Err(Error::StorageFailed) + ); + + let storage = Arc::new(FaultStorage::new(90)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Error( + SigningErrorKind::SignerRejected, + ))), + Arc::new(MockSink), + ); + let push = request(90, "wss://fault.example"); + block_on(engine.prepare_push(push.clone())).unwrap(); + storage.fault_nth_artifact(2); + assert_eq!( + block_on(engine.sign_prepared(push)), + Err(Error::StorageFailed) + ); + + for (byte, nth) in [(91, 1), (92, 2)] { + let storage = Arc::new(FaultStorage::new(byte)); + let sink = Arc::new(ScriptedSink::new([DeliveryBehavior::Outcomes(vec![ + DeliveryOutcome::accepted(), + ])])); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + sink, + ); + let push = request(byte, "wss://fault.example"); + execute_to_admitted(&engine, &push); + storage.fault_nth_plan(nth); + assert_eq!( + block_on(engine.deliver_push(push.operation_id())), + Err(Error::StorageFailed) + ); + } +} + +#[test] fn signing_claim_recovery_respects_exact_and_non_replayable_capabilities() { let exact_storage = Arc::new(MemoryStorage::new( SourceGeneration::new([8; 32]).expect("generation"), @@ -651,6 +1250,10 @@ fn signing_failures_persist_retry_indeterminate_terminal_and_cancelled_states() .signing_state(), radroots_storage::authored::SigningState::Indeterminate ); + assert_eq!( + block_on(engine.sign_prepared(uncertain)), + Err(Error::SigningIndeterminate) + ); let cancelled = Arc::new(MockSigner::new(SignBehavior::Error( SigningErrorKind::SignerCancelled, @@ -669,6 +1272,10 @@ fn signing_failures_persist_retry_indeterminate_terminal_and_cancelled_states() .signing_state(), radroots_storage::authored::SigningState::Cancelled ); + assert_eq!( + block_on(engine.sign_prepared(cancelled_push)), + Err(Error::SignerFailed) + ); let terminal = Arc::new(MockSigner::new(SignBehavior::Error( SigningErrorKind::SignerRejected, @@ -687,6 +1294,20 @@ fn signing_failures_persist_retry_indeterminate_terminal_and_cancelled_states() .signing_state(), radroots_storage::authored::SigningState::FailedTerminal ); + assert_eq!( + block_on(engine.sign_prepared(terminal_push)), + Err(Error::SignerFailed) + ); + + let deadline = Arc::new(MockSigner::new(SignBehavior::Error( + SigningErrorKind::DeadlineExceeded, + ))); + let (engine, _) = setup_engine(deadline); + let deadline_push = request(14, "wss://relay.example"); + assert_eq!( + block_on(engine.sign_prepared(deadline_push)), + Err(Error::SignerDeadlineExceeded) + ); } #[tokio::test] @@ -864,7 +1485,22 @@ fn authored_delivery_persists_retry_evidence_and_complete_settlement() { SatisfactionClass::Accepted, TargetPolicy::all(), ); - block_on(engine.sign_and_enqueue(push.clone())).expect("prepare authored delivery"); + execute_to_admitted(&engine, &push); + let admitted = block_on(engine.push_status(push.operation_id())) + .expect("admitted status") + .expect("admitted operation"); + assert_eq!( + engine.retry_decision(admitted.delivery_plan(), 0), + Err(Error::ClockUnavailable) + ); + assert_eq!( + engine.retry_decision(admitted.delivery_plan(), 1_800_000_200_100), + Ok(SyncRetryDecision::Ready) + ); + assert_eq!( + engine.retry_decision(admitted.delivery_plan(), 1_800_000_300_000), + Ok(SyncRetryDecision::Expired) + ); let first = block_on(engine.deliver_push(push.operation_id())).expect("first attempt"); assert!(!first.is_replay()); @@ -876,6 +1512,14 @@ fn authored_delivery_persists_retry_evidence_and_complete_settlement() { .expect("durable retry") .not_before_unix_ms(); assert_eq!( + engine.retry_decision(first.plan(), retry_at - 1), + Ok(SyncRetryDecision::DeferredUntil { unix_ms: retry_at }) + ); + assert_eq!( + engine.retry_decision(first.plan(), retry_at), + Ok(SyncRetryDecision::Ready) + ); + assert_eq!( block_on(engine.deliver_push(push.operation_id())), Err(Error::DeliveryDeferred) ); @@ -892,6 +1536,10 @@ fn authored_delivery_persists_retry_evidence_and_complete_settlement() { clock.0.store(retry_at, Ordering::Relaxed); let second = block_on(engine.deliver_push(push.operation_id())).expect("retry attempt"); assert_eq!(second.plan().state(), AuthoredDeliveryState::Satisfied); + assert_eq!( + engine.retry_decision(second.plan(), retry_at), + Ok(SyncRetryDecision::Satisfied) + ); assert_eq!(second.plan().attempt_count(), 2); assert_eq!(second.plan().attempts().len(), 2); let replay = block_on(engine.deliver_push(push.operation_id())).expect("terminal replay"); @@ -917,10 +1565,14 @@ fn invalid_authored_delivery_receipt_terminalizes_without_hot_loop() { })); let ((engine, _), _) = setup_engine_with_sink(signer, sink.clone()); let push = request(74, "wss://one.example"); - block_on(engine.sign_and_enqueue(push.clone())).expect("prepare authored delivery"); + execute_to_admitted(&engine, &push); let failed = block_on(engine.deliver_push(push.operation_id())).expect("durable failure"); assert_eq!(failed.plan().state(), AuthoredDeliveryState::FailedTerminal); + assert_eq!( + engine.retry_decision(failed.plan(), 1_800_000_200_100), + Ok(SyncRetryDecision::Exhausted) + ); assert_eq!(failed.plan().attempt_count(), 1); assert_eq!( failed.plan().last_failure().expect("failure").code(), @@ -940,14 +1592,232 @@ fn invalid_authored_delivery_receipt_terminalizes_without_hot_loop() { } #[test] -fn authored_delivery_claims_fence_concurrent_and_stale_workers() { - let pending_sink = Arc::new(ScriptedSink::new([DeliveryBehavior::Pending])); +fn sink_failures_deadlines_and_missing_capabilities_are_durable_and_bounded() { + for (byte, behavior, expected) in [ + ( + 77, + DeliveryBehavior::Failure(Retryability::Retryable), + AuthoredDeliveryState::Retryable, + ), + ( + 78, + DeliveryBehavior::Failure(Retryability::Terminal), + AuthoredDeliveryState::FailedTerminal, + ), + ( + 79, + DeliveryBehavior::MismatchedFailure, + AuthoredDeliveryState::FailedTerminal, + ), + ] { + let sink = Arc::new(ScriptedSink::new([behavior])); + let signer = Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })); + let ((engine, _), _) = setup_engine_with_sink(signer, sink); + let push = request(byte, "wss://failure.example"); + execute_to_admitted(&engine, &push); + let delivered = + block_on(engine.deliver_push(push.operation_id())).expect("durable failure"); + assert_eq!(delivered.plan().state(), expected); + } + let signer = Arc::new(MockSigner::new(SignBehavior::Success { completed_at_unix_ms: 1_800_000_200_500, })); - let ((engine, storage), clock) = setup_engine_with_sink(signer, pending_sink.clone()); - let push = request(75, "wss://one.example"); - block_on(engine.sign_and_enqueue(push.clone())).expect("prepare authored delivery"); + let ((engine, _), clock) = + setup_engine_with_sink(signer, Arc::new(ScriptedSink::new(std::iter::empty()))); + let push = request(80, "wss://deadline.example"); + execute_to_admitted(&engine, &push); + clock + .0 + .store(push.delivery_deadline_unix_ms(), Ordering::Relaxed); + let expired = block_on(engine.deliver_push(push.operation_id())).expect("deadline evidence"); + assert_eq!( + expired.plan().state(), + AuthoredDeliveryState::FailedTerminal + ); + assert_eq!( + expired.plan().last_failure().expect("failure").code(), + "delivery_deadline_exceeded" + ); + + let storage = Arc::new(MemoryStorage::new( + SourceGeneration::new([81; 32]).expect("generation"), + )); + let capability: Arc<dyn SyncStorage> = storage; + let no_signer = Engine::builder( + capability, + Arc::new(TestClock(AtomicU64::new(1_800_000_200_000))), + Arc::new(TestIds(AtomicU64::new(10))), + DeadlinePolicy::new(10_000, 10_000, 10_000).unwrap(), + ) + .sink(Arc::new(MockSink)) + .build() + .unwrap(); + assert_eq!( + block_on(no_signer.sign_prepared(request(81, "wss://missing.example"))), + Err(Error::MissingSigner) + ); + + let storage = Arc::new(MemoryStorage::new( + SourceGeneration::new([82; 32]).expect("generation"), + )); + let capability: Arc<dyn SyncStorage> = storage; + let no_sink = Engine::builder( + capability, + Arc::new(TestClock(AtomicU64::new(1_800_000_200_000))), + Arc::new(TestIds(AtomicU64::new(10))), + DeadlinePolicy::new(10_000, 10_000, 10_000).unwrap(), + ) + .source(Arc::new(MockSource)) + .build() + .unwrap(); + assert_eq!( + block_on(no_sink.deliver_push(SyncId::new([82; 16]).unwrap())), + Err(Error::MissingSink) + ); + + let signer = Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })); + let (engine, _) = setup_engine(signer); + let unsigned = request(83, "wss://unsigned.example"); + block_on(engine.prepare_push(unsigned.clone())).unwrap(); + assert_eq!( + block_on(engine.admit_signed(unsigned.operation_id())), + Err(Error::InvalidSignerOutput) + ); + assert_eq!( + block_on(engine.deliver_push(unsigned.operation_id())), + Err(Error::InvalidSignerOutput) + ); +} + +#[test] +fn active_authored_claims_and_admission_storage_failures_are_fenced() { + let storage = Arc::new(FaultStorage::new(93)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(93, "wss://claim.example"); + block_on(engine.prepare_push(push.clone())).unwrap(); + let artifact = block_on(engine.push_status(push.operation_id())) + .unwrap() + .unwrap() + .artifact() + .clone(); + let claim = radroots_storage::authored::WorkClaim::new( + [93; 16], + "external-signer", + std::num::NonZeroU64::MIN, + 1_800_000_200_000, + 1_800_000_210_000, + artifact.revision(), + ) + .unwrap(); + block_on(AuthoredAtomicStorage::execute_authored( + storage.as_ref(), + radroots_storage::authored_atomic::AuthoredAtomicCommand::Claim( + radroots_storage::authored_atomic::ClaimAuthoredWork::new( + radroots_storage::authored_atomic::ClaimAuthoredTarget::ArtifactSigning( + artifact.artifact_id(), + ), + claim, + ), + ), + )) + .unwrap(); + assert_eq!( + block_on(engine.sign_prepared(push)), + Err(Error::WorkClaimConflict) + ); + + let storage = Arc::new(FaultStorage::new(94)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(94, "wss://claim.example"); + block_on(engine.sign_prepared(push.clone())).unwrap(); + let artifact = block_on(engine.push_status(push.operation_id())) + .unwrap() + .unwrap() + .artifact() + .clone(); + let claim = radroots_storage::authored::WorkClaim::new( + [94; 16], + "external-admission", + std::num::NonZeroU64::MIN, + 1_800_000_200_500, + 1_800_000_210_500, + artifact.revision(), + ) + .unwrap(); + block_on(AuthoredAtomicStorage::execute_authored( + storage.as_ref(), + radroots_storage::authored_atomic::AuthoredAtomicCommand::Claim( + radroots_storage::authored_atomic::ClaimAuthoredWork::new( + radroots_storage::authored_atomic::ClaimAuthoredTarget::ArtifactAdmission( + artifact.artifact_id(), + ), + claim, + ), + ), + )) + .unwrap(); + assert_eq!( + block_on(engine.admit_signed(push.operation_id())), + Err(Error::WorkClaimConflict) + ); + + for (byte, fault, expected) in [ + (95, 1, radroots_storage::authored::AdmissionState::Rejected), + (96, 2, radroots_storage::authored::AdmissionState::Retryable), + ] { + let storage = Arc::new(FaultStorage::new(byte)); + let engine = fault_engine( + storage.clone(), + Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })), + Arc::new(MockSink), + ); + let push = request(byte, "wss://admission-error.example"); + block_on(engine.sign_prepared(push.clone())).unwrap(); + storage.fail_admission_with(fault); + assert_eq!( + block_on(engine.admit_signed(push.operation_id())), + Err(Error::AdmissionFailed), + "admission fault {fault}" + ); + assert_eq!( + block_on(engine.push_status(push.operation_id())) + .unwrap() + .unwrap() + .artifact() + .admission_state(), + expected + ); + } +} + +#[test] +fn authored_delivery_claims_fence_concurrent_and_stale_workers() { + let pending_sink = Arc::new(ScriptedSink::new([DeliveryBehavior::Pending])); + let signer = Arc::new(MockSigner::new(SignBehavior::Success { + completed_at_unix_ms: 1_800_000_200_500, + })); + let ((engine, storage), clock) = setup_engine_with_sink(signer, pending_sink.clone()); + let push = request(75, "wss://one.example"); + execute_to_admitted(&engine, &push); let mut pending = Box::pin(engine.deliver_push(push.operation_id())).fuse(); let mut context = std::task::Context::from_waker(noop_waker_ref()); @@ -961,6 +1831,16 @@ fn authored_delivery_claims_fence_concurrent_and_stale_workers() { .expect("delivery claim") .expires_at_unix_ms(); assert_eq!( + engine.retry_decision(claimed.delivery_plan(), expires_at - 1), + Ok(SyncRetryDecision::InFlightUntil { + unix_ms: expires_at, + }) + ); + assert_eq!( + engine.retry_decision(claimed.delivery_plan(), expires_at), + Ok(SyncRetryDecision::Ready) + ); + assert_eq!( block_on(engine.deliver_push(push.operation_id())), Err(Error::WorkClaimConflict) ); @@ -1027,9 +1907,13 @@ async fn sqlite_authored_delivery_retry_survives_reopen() { .build() .expect("engine"); engine - .sign_and_enqueue(push.clone()) + .sign_prepared(push.clone()) .await - .expect("prepare authored delivery"); + .expect("sign prepared push"); + engine + .admit_signed(push.operation_id()) + .await + .expect("admit signed push"); let first = engine .deliver_push(push.operation_id()) .await @@ -1084,474 +1968,3 @@ async fn sqlite_authored_delivery_retry_survives_reopen() { assert_eq!(completed.plan().state(), AuthoredDeliveryState::Satisfied); assert_eq!(sink.requests.lock().expect("request log").len(), 1); } - -#[test] -fn signer_rejection_challenge_and_timeout_fail_before_enqueue() { - for kind in [ - SigningErrorKind::SignerRejected, - SigningErrorKind::SignerCapabilityMissing, - SigningErrorKind::SignerTimeout, - SigningErrorKind::SignerOutputInvalid, - ] { - let signer = Arc::new(MockSigner::new(SignBehavior::Error(kind))); - let (engine, storage) = setup_engine(signer); - assert_eq!( - block_on(engine.sign_and_enqueue(request(2, "wss://relay.example"))), - Err(Error::SignerFailed) - ); - assert!( - block_on(Outbox::item( - &*storage, - radroots_storage::outbox::OutboxItemId::new([2; 16]).expect("item id"), - )) - .expect("outbox lookup") - .is_none() - ); - } - - let late = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_212_000, - })); - let (engine, _) = setup_engine(late); - assert_eq!( - block_on(engine.sign_and_enqueue(request(3, "wss://relay.example"))), - Err(Error::SignerDeadlineExceeded) - ); -} - -#[test] -fn idempotency_conflict_and_cancellation_before_commit_fail_closed() { - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let (engine, _) = setup_engine(signer.clone()); - block_on(engine.sign_and_enqueue(request(4, "wss://relay.example"))).expect("enqueue"); - assert_eq!( - block_on(engine.sign_and_enqueue(request(4, "wss://other.example"))), - Err(Error::StorageConflict) - ); - assert_eq!(signer.calls.load(Ordering::Relaxed), 1); - - let pending = Arc::new(MockSigner::new(SignBehavior::Pending)); - let (engine, storage) = setup_engine(pending); - let pending_push = request(5, "wss://relay.example"); - let mut future = Box::pin(engine.sign_and_enqueue(pending_push.clone())).fuse(); - let mut context = std::task::Context::from_waker(noop_waker_ref()); - assert!(future.poll_unpin(&mut context).is_pending()); - drop(future); - let status = block_on(engine.push_status(SyncId::new([5; 16]).expect("operation"))) - .expect("authored status") - .expect("prepared operation"); - assert!(status.artifact().signing_claim().is_some()); - assert_eq!( - block_on(engine.sign_prepared(pending_push)), - Err(Error::WorkClaimConflict) - ); - assert!( - block_on(Journal::operation( - &*storage, - OperationInstanceId::new([5; 16]).expect("instance id"), - )) - .expect("legacy journal lookup") - .is_none() - ); - assert!( - block_on(Outbox::item( - &*storage, - radroots_storage::outbox::OutboxItemId::new([5; 16]).expect("item id"), - )) - .expect("outbox lookup") - .is_none() - ); -} - -#[test] -fn delivery_evaluates_any_all_quorum_required_and_partial_outcomes() { - let sink = Arc::new(ScriptedSink::new([ - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::accepted(), - DeliveryOutcome::rejected(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::accepted(), - DeliveryOutcome::delivered(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::delivered(), - DeliveryOutcome::accepted(), - DeliveryOutcome::rejected(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::rejected(), - DeliveryOutcome::accepted(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::accepted(), - DeliveryOutcome::unavailable(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::rejected(), - DeliveryOutcome::unavailable(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::rejected(), - DeliveryOutcome::unavailable(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::unavailable(), - DeliveryOutcome::rejected(), - ]), - ])); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let ((engine, _), _) = setup_engine_with_sink(signer, sink.clone()); - let two = ["wss://one.example", "wss://two.example"]; - let three = [ - "wss://one.example", - "wss://two.example", - "wss://three.example", - ]; - let required = Target::new(TransportId::NOSTR, two[1]) - .expect("required target") - .fingerprint() - .clone(); - let plans = [ - request_with_policy(11, &two, SatisfactionClass::Accepted, TargetPolicy::any()), - request_with_policy(12, &two, SatisfactionClass::Accepted, TargetPolicy::all()), - request_with_policy( - 13, - &three, - SatisfactionClass::Accepted, - TargetPolicy::quorum(2).expect("quorum"), - ), - request_with_policy( - 14, - &two, - SatisfactionClass::Accepted, - TargetPolicy::required(vec![required]).expect("required policy"), - ), - request_with_policy(15, &two, SatisfactionClass::Accepted, TargetPolicy::all()), - request_with_policy(16, &two, SatisfactionClass::Accepted, TargetPolicy::all()), - request_with_policy(17, &two, SatisfactionClass::Accepted, TargetPolicy::any()), - request_with_policy( - 18, - &two, - SatisfactionClass::Accepted, - TargetPolicy::required(vec![ - Target::new(TransportId::NOSTR, two[1]) - .expect("terminal required target") - .fingerprint() - .clone(), - ]) - .expect("terminal required policy"), - ), - ]; - for plan in plans { - block_on(engine.sign_and_enqueue(plan)).expect("enqueue delivery plan"); - } - - let delivered = block_on(engine.deliver_pending(delivery_run(31, 8))).expect("deliver batch"); - assert_eq!(delivered.succeeded(), 8); - assert_eq!(delivered.failed(), 0); - let records: Vec<_> = delivered - .outcomes() - .iter() - .map(|outcome| outcome.as_ref().expect("durable outcome")) - .collect(); - assert_eq!(records[0].stage(), OutboxStage::Satisfied); - assert_eq!(records[1].stage(), OutboxStage::Satisfied); - assert_eq!(records[2].stage(), OutboxStage::Satisfied); - assert_eq!(records[3].stage(), OutboxStage::Satisfied); - assert_eq!(records[4].stage(), OutboxStage::Retryable); - assert_eq!(records[4].satisfaction(), SatisfactionResult::Pending); - assert_eq!(records[5].stage(), OutboxStage::Exhausted); - assert_eq!(records[6].stage(), OutboxStage::Retryable); - assert_eq!(records[7].stage(), OutboxStage::Exhausted); - for record in records { - assert_eq!(record.evidence().len(), record.request().target_set().len()); - let expected: Vec<_> = record - .request() - .target_set() - .targets() - .iter() - .map(|target| target.fingerprint()) - .collect(); - let actual: Vec<_> = record - .evidence() - .iter() - .map(|evidence| evidence.target()) - .collect(); - assert_eq!(actual, expected); - } - assert_eq!(sink.requests.lock().expect("request log").len(), 8); -} - -#[test] -fn transport_failure_is_durable_and_retry_preserves_the_exact_plan() { - let sink = Arc::new(ScriptedSink::new([ - DeliveryBehavior::AdapterError, - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::accepted(), - DeliveryOutcome::unavailable(), - ]), - DeliveryBehavior::Outcomes(vec![ - DeliveryOutcome::unavailable(), - DeliveryOutcome::accepted(), - ]), - ])); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let ((engine, _), _) = setup_engine_with_sink(signer, sink.clone()); - block_on(engine.sign_and_enqueue(request_with_policy( - 21, - &["wss://one.example", "wss://two.example"], - SatisfactionClass::Accepted, - TargetPolicy::all(), - ))) - .expect("enqueue retry plan"); - - let first = block_on(engine.deliver_pending(delivery_run(41, 1))).expect("first delivery"); - let first = first.outcomes()[0].as_ref().expect("durable failure"); - assert_eq!(first.stage(), OutboxStage::Retryable); - assert_eq!(first.evidence().len(), 2); - assert!(first.evidence().iter().all(|evidence| { - !evidence.was_attempted() && evidence.outcome() == &DeliveryOutcome::unavailable() - })); - - let second = block_on(engine.deliver_pending(delivery_run(42, 1))).expect("partial retry"); - let second = second.outcomes()[0].as_ref().expect("durable retry"); - assert_eq!(second.stage(), OutboxStage::Retryable); - assert_eq!(second.last_attempt().expect("attempt").get(), 2); - - let third = block_on(engine.deliver_pending(delivery_run(43, 1))).expect("completed retry"); - let third = third.outcomes()[0].as_ref().expect("durable retry"); - assert_eq!(third.stage(), OutboxStage::Satisfied); - assert_eq!(third.last_attempt().expect("attempt").get(), 3); - assert_eq!(third.evidence().len(), 6); - assert_eq!( - third - .latest_target_evidence(third.request().target_set().targets()[0].fingerprint()) - .expect("latest first target evidence") - .outcome(), - &DeliveryOutcome::unavailable() - ); - assert!( - third.evidence()[2] - .outcome() - .satisfies(SatisfactionClass::Accepted) - ); - let requests = sink.requests.lock().expect("request log"); - assert_eq!(requests.len(), 3); - assert_eq!(requests[0], requests[1]); - assert_eq!(requests[1], requests[2]); -} - -#[test] -fn malformed_receipts_release_work_and_expired_plans_terminalize() { - let malformed_sink = Arc::new(ScriptedSink::new([DeliveryBehavior::MismatchedRequest])); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let ((engine, storage), _) = setup_engine_with_sink(signer, malformed_sink); - let enqueued = block_on(engine.sign_and_enqueue(request(31, "wss://one.example"))) - .expect("enqueue malformed receipt plan"); - let malformed = - block_on(engine.deliver_pending(delivery_run(51, 1))).expect("malformed delivery pass"); - assert_eq!(malformed.outcomes(), &[Err(Error::InvalidDeliveryRequest)]); - let released = block_on(Outbox::item(&*storage, enqueued.outbox().item_id())) - .expect("released lookup") - .expect("released record"); - assert_eq!(released.stage(), OutboxStage::Pending); - assert!(released.lease().is_none()); - assert!(released.evidence().is_empty()); - - let expired_sink = Arc::new(ScriptedSink::new([])); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let ((engine, _), clock) = setup_engine_with_sink(signer, expired_sink.clone()); - let enqueued = block_on(engine.sign_and_enqueue(request(32, "wss://one.example"))) - .expect("enqueue expiring plan"); - clock.0.store( - enqueued.outbox().request().deadline_unix_ms() + 1, - Ordering::Relaxed, - ); - let expired = - block_on(engine.deliver_pending(delivery_run(52, 1))).expect("expired delivery pass"); - let expired = expired.outcomes()[0] - .as_ref() - .expect("durable terminal state"); - assert_eq!(expired.stage(), OutboxStage::Exhausted); - assert_eq!(expired.satisfaction(), SatisfactionResult::Exhausted); - assert!(expired.evidence()[0].outcome().is_terminal()); - assert!( - expired_sink - .requests - .lock() - .expect("request log") - .is_empty() - ); -} - -#[test] -fn delivery_run_rejects_unbounded_claims() { - let owner = LeaseOwner::parse("sync-delivery-test").expect("lease owner"); - let seed = SyncId::new([71; 16]).expect("lease seed"); - assert_eq!( - DeliveryRunRequest::new(owner.clone(), seed, 0, 1), - Err(Error::InvalidDeliveryRequest) - ); - assert_eq!( - DeliveryRunRequest::new(owner, seed, 1_000, 0), - Err(Error::InvalidDeliveryRequest) - ); - let owner = LeaseOwner::parse("sync-delivery-test").expect("lease owner"); - assert_eq!( - DeliveryRunRequest::new(owner.clone(), seed, 86_400_001, 1), - Err(Error::InvalidDeliveryRequest) - ); - assert_eq!( - DeliveryRunRequest::new( - owner, - seed, - 1_000, - radroots_storage::outbox::OUTBOX_CLAIM_LIMIT_MAX + 1, - ), - Err(Error::InvalidDeliveryRequest) - ); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let (engine, _) = setup_engine(signer); - let over_engine_budget = DeliveryRunRequest::new( - LeaseOwner::parse("sync-delivery-test").expect("lease owner"), - seed, - 10_001, - 1, - ) - .expect("globally bounded delivery run"); - assert_eq!( - block_on(engine.deliver_pending(over_engine_budget)), - Err(Error::InvalidDeliveryRequest) - ); - - let valid = request(72, "wss://one.example"); - let invalid_quorum = PushRequest::new( - valid.operation_id(), - valid.idempotency_key().clone(), - valid.actor().clone(), - valid.plan().clone(), - valid.targets().clone(), - SatisfactionPolicy::new( - SatisfactionClass::Accepted, - TargetPolicy::quorum(2).expect("quorum"), - ), - valid.delivery_deadline_unix_ms(), - valid.cancellation(), - ); - assert!(matches!(invalid_quorum, Err(Error::InvalidPushRequest))); - let absent = Target::new(TransportId::NOSTR, "wss://absent.example") - .expect("absent target") - .fingerprint() - .clone(); - let invalid_required = PushRequest::new( - valid.operation_id(), - valid.idempotency_key().clone(), - valid.actor().clone(), - valid.plan().clone(), - valid.targets().clone(), - SatisfactionPolicy::new( - SatisfactionClass::Accepted, - TargetPolicy::required(vec![absent]).expect("required"), - ), - valid.delivery_deadline_unix_ms(), - valid.cancellation(), - ); - assert!(matches!(invalid_required, Err(Error::InvalidPushRequest))); -} - -#[test] -fn retry_decisions_are_passive_typed_and_deadline_aware() { - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let (engine, storage) = setup_engine(signer); - let enqueued = block_on(engine.sign_and_enqueue(request(61, "wss://one.example"))) - .expect("enqueue retry decision plan"); - let now = enqueued.outbox().created_at_unix_ms() + 1; - assert_eq!( - engine.retry_decision(enqueued.outbox(), now), - Ok(SyncRetryDecision::Ready) - ); - let claimed = block_on(Outbox::claim( - &*storage, - ClaimOutboxItems::new( - LeaseOwner::parse("retry-decision-test").expect("owner"), - LeaseId::new([81; 16]).expect("lease seed"), - now, - now + 100, - 1, - ) - .expect("claim request"), - )) - .expect("claim") - .pop() - .expect("claimed plan"); - assert_eq!( - engine.retry_decision(claimed.record(), now + 1), - Ok(SyncRetryDecision::InFlightUntil { unix_ms: now + 100 }) - ); - assert_eq!( - engine.retry_decision(claimed.record(), now + 100), - Ok(SyncRetryDecision::Ready) - ); - let deferred = block_on(Outbox::release( - &*storage, - claimed.record().item_id(), - claimed.lease().id(), - claimed.record().revision(), - now + 2, - Some(now + 50), - )) - .expect("release with deferral"); - assert_eq!( - engine.retry_decision(&deferred, now + 3), - Ok(SyncRetryDecision::DeferredUntil { unix_ms: now + 50 }) - ); - assert_eq!( - engine.retry_decision(&deferred, now + 50), - Ok(SyncRetryDecision::Ready) - ); - assert_eq!( - engine.retry_decision(&deferred, deferred.request().deadline_unix_ms()), - Ok(SyncRetryDecision::Expired) - ); - assert_eq!( - engine.retry_decision(&deferred, 0), - Err(Error::ClockUnavailable) - ); - - let sink = Arc::new(ScriptedSink::new([ - DeliveryBehavior::Outcomes(vec![DeliveryOutcome::accepted()]), - DeliveryBehavior::Outcomes(vec![DeliveryOutcome::rejected()]), - ])); - let signer = Arc::new(MockSigner::new(SignBehavior::Success { - completed_at_unix_ms: 1_800_000_200_500, - })); - let ((engine, _), _) = setup_engine_with_sink(signer, sink); - block_on(engine.sign_and_enqueue(request(62, "wss://one.example"))) - .expect("enqueue satisfied plan"); - block_on(engine.sign_and_enqueue(request(63, "wss://two.example"))) - .expect("enqueue exhausted plan"); - let delivered = block_on(engine.deliver_pending(delivery_run(82, 2))).expect("deliver plans"); - assert_eq!( - engine.retry_decision(delivered.outcomes()[0].as_ref().expect("satisfied"), now), - Ok(SyncRetryDecision::Satisfied) - ); - assert_eq!( - engine.retry_decision(delivered.outcomes()[1].as_ref().expect("exhausted"), now), - Ok(SyncRetryDecision::Exhausted) - ); -} diff --git a/crates/sync/tests/reliability_scenarios.rs b/crates/sync/tests/reliability_scenarios.rs @@ -1,282 +0,0 @@ -use std::sync::{ - Arc, - atomic::{AtomicU64, AtomicUsize, Ordering}, -}; - -use futures_executor::block_on; -use radroots_event::{SignedEvent, wire::Nip01EventWire}; -use radroots_protocol::runtime::v1::OperationId; -use radroots_storage::{ - Journal, Outbox, - event::SourceGeneration, - journal::{IdempotencyDigest, IdempotencyKey, OperationInstanceId, PrepareOperation}, - memory::MemoryStorage, - outbox::{DeliveryPlanDigest, EnqueueOutboxItem, LeaseOwner, OutboxItemId, OutboxStage}, -}; -use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; -use radroots_sync::{ - Engine, - policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, - push::DeliveryRunRequest, -}; -use radroots_transport::{ - DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure, SinkStatus, - Target, TargetSet, TransportId, - capability::{Availability, Maturity, SinkCapabilities}, - outcome::DeliveryOutcome, - policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, - sink::{DeliveryPayload, DeliveryTargetReceipt}, -}; - -struct SequenceClock(AtomicU64); - -impl Clock for SequenceClock { - fn now_unix_ms(&self) -> Result<u64, Error> { - Ok(self.0.fetch_add(1, Ordering::Relaxed)) - } -} - -struct SequenceIds(AtomicU64); - -impl IdSource for SequenceIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { - let byte = u8::try_from(self.0.fetch_add(1, Ordering::Relaxed)) - .map_err(|_| Error::InvalidSyncId)?; - SyncId::new([byte; 16]) - } -} - -struct RecoverySink(AtomicUsize); - -impl EventSink for RecoverySink { - fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { - Ok(SinkStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SinkCapabilities::DELIVER, - "ready", - )) - }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { - let call = self.0.fetch_add(1, Ordering::Relaxed); - Box::pin(async move { - if call == 0 { - return Err(SinkFailure::for_request( - &request, - "recovery_unavailable", - radroots_transport::outcome::Retryability::Retryable, - None, - None, - Vec::new(), - ) - .expect("valid recovery failure")); - } - Ok(DeliveryReceipt::for_request( - &request, - request - .target_set() - .targets() - .iter() - .cloned() - .map(|target| { - DeliveryTargetReceipt::attempted(target, DeliveryOutcome::accepted()) - }) - .collect(), - ) - .expect("valid recovery receipt")) - }) - } -} - -fn signed_event() -> SignedEvent { - let mut wire = Nip01EventWire { - id: "0".repeat(64), - pubkey: "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df".to_owned(), - created_at: 1_800_000_100, - kind: 0, - tags: vec![], - content: "reliability-scenario".to_owned(), - sig: "42".repeat(64), - extra: Default::default(), - }; - wire.id = wire.computed_event_id().expect("event id").to_hex(); - let raw = serde_json::json!({ - "id": &wire.id, - "pubkey": &wire.pubkey, - "created_at": wire.created_at, - "kind": wire.kind, - "tags": &wire.tags, - "content": &wire.content, - "sig": &wire.sig, - }) - .to_string(); - SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") -} - -fn enqueue_request() -> EnqueueOutboxItem { - let target = Target::new(TransportId::NOSTR, "wss://reliability.example").expect("target"); - let request = DeliveryRequest::new( - "sync-reliability", - DeliveryPayload::new(signed_event()), - TargetSet::new(vec![target]).expect("targets"), - SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), - 10_000, - ) - .expect("request"); - EnqueueOutboxItem::new( - OutboxItemId::new([5; 16]).expect("item id"), - OperationInstanceId::new([6; 16]).expect("operation id"), - DeliveryPlanDigest::new([7; 32]), - request, - 10, - ) - .expect("enqueue") -} - -fn engine( - storage: Arc<dyn SyncStorage>, - sink: Arc<RecoverySink>, - clock_start: u64, - id_start: u64, -) -> Engine { - Engine::builder( - storage, - Arc::new(SequenceClock(AtomicU64::new(clock_start))), - Arc::new(SequenceIds(AtomicU64::new(id_start))), - DeadlinePolicy::new(1_000, 1_000, 1_000).expect("deadlines"), - ) - .sink(sink) - .build() - .expect("engine") -} - -fn run(seed: u8) -> DeliveryRunRequest { - DeliveryRunRequest::new( - LeaseOwner::parse("sync-reliability").expect("owner"), - SyncId::new([seed; 16]).expect("seed"), - 100, - 1, - ) - .expect("run") -} - -async fn retry_scenario(storage: Arc<dyn SyncStorage>) { - Journal::prepare( - storage.as_ref(), - PrepareOperation::new( - OperationInstanceId::new([6; 16]).expect("operation id"), - OperationId::SyncPush, - IdempotencyKey::parse("sync-reliability").expect("idempotency key"), - IdempotencyDigest::new([8; 32]), - 9, - ) - .expect("prepare operation"), - ) - .await - .expect("prepare"); - Outbox::enqueue(storage.as_ref(), enqueue_request()) - .await - .expect("enqueue"); - let sync = engine( - storage, - Arc::new(RecoverySink(AtomicUsize::new(0))), - 100, - 20, - ); - let first = sync.deliver_pending(run(30)).await.expect("first attempt"); - assert_eq!( - first.outcomes()[0].as_ref().expect("durable retry").stage(), - OutboxStage::Retryable - ); - let second = sync.deliver_pending(run(31)).await.expect("retry"); - assert_eq!( - second.outcomes()[0] - .as_ref() - .expect("durable success") - .stage(), - OutboxStage::Satisfied - ); - let status = sync.status(&[]).await.expect("status").to_protocol(); - assert_eq!(status.outbox.satisfied, 1); -} - -#[test] -fn memory_runs_the_durable_retry_scenario() { - let storage: Arc<dyn SyncStorage> = Arc::new(MemoryStorage::new( - SourceGeneration::new([9; 32]).expect("generation"), - )); - block_on(retry_scenario(storage)); -} - -#[tokio::test] -async fn sqlite_recovers_retryable_work_after_reopen() { - let directory = tempfile::tempdir().expect("directory"); - let paths = Paths::from_directory(directory.path()).expect("paths"); - { - let store = Arc::new( - SqliteStorage::open( - OpenOptions::new(paths.clone(), OpenMode::Create) - .with_source_generation(SourceGeneration::new([10; 32]).expect("generation"), 1) - .expect("source generation"), - ) - .await - .expect("open"), - ); - Journal::prepare( - store.as_ref(), - PrepareOperation::new( - OperationInstanceId::new([6; 16]).expect("operation id"), - OperationId::SyncPush, - IdempotencyKey::parse("sync-reliability").expect("idempotency key"), - IdempotencyDigest::new([8; 32]), - 9, - ) - .expect("prepare operation"), - ) - .await - .expect("prepare"); - Outbox::enqueue(store.as_ref(), enqueue_request()) - .await - .expect("enqueue"); - let storage: Arc<dyn SyncStorage> = store; - let sync = engine( - storage, - Arc::new(RecoverySink(AtomicUsize::new(0))), - 100, - 20, - ); - let first = sync.deliver_pending(run(40)).await.expect("first attempt"); - assert_eq!( - first.outcomes()[0].as_ref().expect("retryable").stage(), - OutboxStage::Retryable - ); - } - let store = Arc::new( - SqliteStorage::open(OpenOptions::new(paths, OpenMode::ReadWriteExisting)) - .await - .expect("reopen"), - ); - let storage: Arc<dyn SyncStorage> = store; - let sync = engine( - storage, - Arc::new(RecoverySink(AtomicUsize::new(1))), - 200, - 40, - ); - let recovered = sync - .deliver_pending(run(41)) - .await - .expect("recovered retry"); - assert_eq!( - recovered.outcomes()[0].as_ref().expect("satisfied").stage(), - OutboxStage::Satisfied - ); -} diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -1,635 +0,0 @@ -use crate::{ - RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportPayload, Target, TargetSet, - target::TargetFingerprint, -}; -use alloc::collections::{BTreeMap, BTreeSet}; -use alloc::string::String; -use alloc::vec::Vec; - -pub const RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES: usize = 256; - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportSatisfactionClass { - Accepted, - Forwarded, - Stored, - Seen, - Delivered, - DurableOrObserved, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTransportSatisfactionPolicy { - NoWait, - Any { - class: RadrootsTransportSatisfactionClass, - }, - All { - class: RadrootsTransportSatisfactionClass, - }, - Quorum { - class: RadrootsTransportSatisfactionClass, - threshold: u16, - }, - RequiredTargets { - class: RadrootsTransportSatisfactionClass, - targets: Vec<TargetFingerprint>, - }, -} - -impl RadrootsTransportSatisfactionPolicy { - pub fn no_wait() -> Self { - Self::NoWait - } - - pub fn any_accepted() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Accepted, - } - } - - pub fn all_accepted() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Accepted, - } - } - - pub fn quorum_accepted(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Accepted, - threshold, - } - } - - pub fn any_forwarded() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Forwarded, - } - } - - pub fn all_forwarded() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Forwarded, - } - } - - pub fn quorum_forwarded(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Forwarded, - threshold, - } - } - - pub fn any_stored() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Stored, - } - } - - pub fn all_stored() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Stored, - } - } - - pub fn quorum_stored(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Stored, - threshold, - } - } - - pub fn any_seen() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Seen, - } - } - - pub fn all_seen() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Seen, - } - } - - pub fn quorum_seen(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Seen, - threshold, - } - } - - pub fn any_delivered() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Delivered, - } - } - - pub fn all_delivered() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Delivered, - } - } - - pub fn quorum_delivered(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Delivered, - threshold, - } - } - - pub fn any_durable_or_observed() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, - } - } - - pub fn all_durable_or_observed() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, - } - } - - pub fn quorum_durable_or_observed(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, - threshold, - } - } - - pub fn required_targets( - class: RadrootsTransportSatisfactionClass, - mut targets: Vec<TargetFingerprint>, - ) -> Result<Self, RadrootsTransportError> { - validate_required_targets(&targets)?; - targets.sort(); - Ok(Self::RequiredTargets { class, targets }) - } - - pub fn target_satisfaction_class(&self) -> Option<RadrootsTransportSatisfactionClass> { - match self { - Self::NoWait => None, - Self::Any { class } - | Self::All { class } - | Self::Quorum { class, .. } - | Self::RequiredTargets { class, .. } => Some(*class), - } - } - - pub fn required_target_fingerprints(&self) -> Option<&[TargetFingerprint]> { - match self { - Self::RequiredTargets { targets, .. } => Some(targets), - Self::NoWait | Self::Any { .. } | Self::All { .. } | Self::Quorum { .. } => None, - } - } - - pub fn required_target_count( - &self, - total_targets: usize, - ) -> Result<usize, RadrootsTransportError> { - if total_targets == 0 && !matches!(self, Self::NoWait) { - return Err(RadrootsTransportError::InvalidSatisfactionPolicy); - } - match self { - Self::NoWait => Ok(0), - Self::All { .. } => Ok(total_targets), - Self::Any { .. } => Ok(1), - Self::Quorum { threshold, .. } - if *threshold > 0 && usize::from(*threshold) <= total_targets => - { - Ok(usize::from(*threshold)) - } - Self::Quorum { .. } => Err(RadrootsTransportError::InvalidSatisfactionPolicy), - Self::RequiredTargets { targets, .. } => { - validate_required_targets(targets)?; - if targets.len() > total_targets { - return Err(RadrootsTransportError::InvalidSatisfactionPolicy); - } - Ok(targets.len()) - } - } - } - - pub fn is_satisfied_by( - &self, - total_targets: usize, - satisfied_targets: usize, - ) -> Result<bool, RadrootsTransportError> { - if matches!(self, Self::RequiredTargets { .. }) { - return Err(RadrootsTransportError::InvalidSatisfactionPolicy); - } - let required = self.required_target_count(total_targets)?; - Ok(satisfied_targets >= required) - } - - pub fn validate_for_target_set( - &self, - target_set: &TargetSet, - ) -> Result<(), RadrootsTransportError> { - self.required_target_count(target_set.len())?; - if let Self::RequiredTargets { targets, .. } = self { - for required in targets { - if !target_set - .targets() - .iter() - .any(|target| target.fingerprint() == required) - { - return Err(RadrootsTransportError::RequiredTargetNotRequested); - } - } - } - Ok(()) - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -enum RadrootsTransportSatisfactionPolicyWire { - NoWait, - Any { - class: RadrootsTransportSatisfactionClass, - }, - All { - class: RadrootsTransportSatisfactionClass, - }, - Quorum { - class: RadrootsTransportSatisfactionClass, - threshold: u16, - }, - RequiredTargets { - class: RadrootsTransportSatisfactionClass, - targets: Vec<TargetFingerprint>, - }, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportSatisfactionPolicy { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - match RadrootsTransportSatisfactionPolicyWire::deserialize(deserializer)? { - RadrootsTransportSatisfactionPolicyWire::NoWait => Ok(Self::NoWait), - RadrootsTransportSatisfactionPolicyWire::Any { class } => Ok(Self::Any { class }), - RadrootsTransportSatisfactionPolicyWire::All { class } => Ok(Self::All { class }), - RadrootsTransportSatisfactionPolicyWire::Quorum { class, threshold } - if threshold > 0 => - { - Ok(Self::Quorum { class, threshold }) - } - RadrootsTransportSatisfactionPolicyWire::Quorum { .. } => Err( - serde::de::Error::custom(RadrootsTransportError::InvalidSatisfactionPolicy), - ), - RadrootsTransportSatisfactionPolicyWire::RequiredTargets { class, targets } => { - Self::required_targets(class, targets).map_err(serde::de::Error::custom) - } - } - } -} - -fn validate_required_targets(targets: &[TargetFingerprint]) -> Result<(), RadrootsTransportError> { - if targets.is_empty() { - return Err(RadrootsTransportError::EmptyRequiredTargetSet); - } - let mut fingerprints = BTreeSet::new(); - for target in targets { - if !fingerprints.insert(target.as_str()) { - return Err(RadrootsTransportError::DuplicateRequiredTargetFingerprint); - } - } - Ok(()) -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportDeliveryRequest { - request_id: String, - payload: RadrootsTransportPayload, - target_set: TargetSet, - satisfaction_policy: RadrootsTransportSatisfactionPolicy, - now_ms: i64, -} - -impl RadrootsTransportDeliveryRequest { - pub fn new( - request_id: impl Into<String>, - payload: RadrootsTransportPayload, - target_set: TargetSet, - satisfaction_policy: RadrootsTransportSatisfactionPolicy, - ) -> Result<Self, RadrootsTransportError> { - let request_id = request_id.into(); - validate_delivery_request_id(request_id.as_str())?; - payload.validate()?; - satisfaction_policy.validate_for_target_set(&target_set)?; - Ok(Self { - request_id, - payload, - target_set, - satisfaction_policy, - now_ms: 0, - }) - } - - pub fn try_with_now_ms(mut self, now_ms: i64) -> Result<Self, RadrootsTransportError> { - validate_delivery_timestamp(now_ms)?; - self.now_ms = now_ms; - Ok(self) - } - - pub fn request_id(&self) -> &str { - self.request_id.as_str() - } - - pub fn payload(&self) -> &RadrootsTransportPayload { - &self.payload - } - - pub fn target_set(&self) -> &TargetSet { - &self.target_set - } - - pub fn satisfaction_policy(&self) -> &RadrootsTransportSatisfactionPolicy { - &self.satisfaction_policy - } - - pub fn now_ms(&self) -> i64 { - self.now_ms - } -} - -fn validate_delivery_request_id(value: &str) -> Result<(), RadrootsTransportError> { - if value.is_empty() { - return Err(RadrootsTransportError::EmptyDeliveryRequestId); - } - if value != value.trim() - || value.chars().any(char::is_control) - || value.len() > RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES - { - return Err(RadrootsTransportError::InvalidDeliveryRequestId); - } - Ok(()) -} - -fn validate_delivery_timestamp(now_ms: i64) -> Result<(), RadrootsTransportError> { - if now_ms < 0 { - return Err(RadrootsTransportError::InvalidDeliveryTimestamp); - } - Ok(()) -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct RadrootsTransportDeliveryRequestWire { - request_id: String, - payload: RadrootsTransportPayload, - target_set: TargetSet, - satisfaction_policy: RadrootsTransportSatisfactionPolicy, - now_ms: i64, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportDeliveryRequest { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = RadrootsTransportDeliveryRequestWire::deserialize(deserializer)?; - Self::new( - wire.request_id, - wire.payload, - wire.target_set, - wire.satisfaction_policy, - ) - .and_then(|request| request.try_with_now_ms(wire.now_ms)) - .map_err(serde::de::Error::custom) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportTargetReceipt { - pub target: Target, - pub attempted: bool, - pub status: RadrootsTransportDeliveryTargetStatus, - pub outcome: RadrootsTransportOutcome, -} - -impl RadrootsTransportTargetReceipt { - pub fn new(target: Target, outcome: RadrootsTransportOutcome) -> Self { - Self::attempted(target, outcome) - } - - pub fn attempted(target: Target, outcome: RadrootsTransportOutcome) -> Self { - Self { - target, - attempted: true, - status: outcome.status, - outcome, - } - } - - pub fn skipped(target: Target, outcome: RadrootsTransportOutcome) -> Self { - Self { - target, - attempted: false, - status: outcome.status, - outcome, - } - } - - fn validate(&self) -> Result<(), RadrootsTransportError> { - self.outcome.validate()?; - if self.status != self.outcome.status { - return Err(RadrootsTransportError::DeliveryTargetReceiptStatusMismatch); - } - if !self.attempted - && self.status.counts_as_accepted_satisfaction() - && self.outcome.kind != RadrootsTransportOutcomeKind::DuplicateAccepted - { - return Err(RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch); - } - Ok(()) - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct RadrootsTransportTargetReceiptWire { - target: Target, - attempted: bool, - status: RadrootsTransportDeliveryTargetStatus, - outcome: RadrootsTransportOutcome, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportTargetReceipt { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = RadrootsTransportTargetReceiptWire::deserialize(deserializer)?; - let receipt = Self { - target: wire.target, - attempted: wire.attempted, - status: wire.status, - outcome: wire.outcome, - }; - receipt.validate().map_err(serde::de::Error::custom)?; - Ok(receipt) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportDeliveryReceipt { - request_id: String, - target_set: TargetSet, - target_receipts: Vec<RadrootsTransportTargetReceipt>, -} - -impl RadrootsTransportDeliveryReceipt { - pub fn for_request( - request: &RadrootsTransportDeliveryRequest, - target_receipts: Vec<RadrootsTransportTargetReceipt>, - ) -> Result<Self, RadrootsTransportError> { - Self::new( - request.request_id(), - request.target_set().clone(), - target_receipts, - ) - } - - pub fn new( - request_id: impl Into<String>, - target_set: TargetSet, - target_receipts: Vec<RadrootsTransportTargetReceipt>, - ) -> Result<Self, RadrootsTransportError> { - let request_id = request_id.into(); - validate_delivery_request_id(request_id.as_str())?; - - let mut receipts_by_fingerprint: BTreeMap<String, RadrootsTransportTargetReceipt> = - BTreeMap::new(); - for receipt in target_receipts { - receipt.validate()?; - let Some(requested_target) = target_set - .targets() - .iter() - .find(|target| target.fingerprint() == receipt.target.fingerprint()) - else { - return Err(RadrootsTransportError::UnexpectedDeliveryTargetReceipt); - }; - if requested_target != &receipt.target { - return Err(RadrootsTransportError::UnexpectedDeliveryTargetReceipt); - } - if receipts_by_fingerprint - .insert(receipt.target.fingerprint().as_str().into(), receipt) - .is_some() - { - return Err(RadrootsTransportError::DuplicateDeliveryTargetReceipt); - } - } - - if receipts_by_fingerprint.len() != target_set.len() { - return Err(RadrootsTransportError::MissingDeliveryTargetReceipt); - } - let target_receipts = target_set - .targets() - .iter() - .map(|target| { - receipts_by_fingerprint - .remove(target.fingerprint().as_str()) - .ok_or(RadrootsTransportError::MissingDeliveryTargetReceipt) - }) - .collect::<Result<Vec<_>, _>>()?; - - Ok(Self { - request_id, - target_set, - target_receipts, - }) - } - - pub fn request_id(&self) -> &str { - self.request_id.as_str() - } - - pub fn target_set(&self) -> &TargetSet { - &self.target_set - } - - pub fn target_receipts(&self) -> &[RadrootsTransportTargetReceipt] { - &self.target_receipts - } - - pub fn validate_for_request( - &self, - request: &RadrootsTransportDeliveryRequest, - ) -> Result<(), RadrootsTransportError> { - if self.request_id() != request.request_id() { - return Err(RadrootsTransportError::DeliveryReceiptRequestIdMismatch); - } - if self.target_set() != request.target_set() { - return Err(RadrootsTransportError::DeliveryReceiptTargetSetMismatch); - } - Ok(()) - } - - pub fn satisfied_target_count( - &self, - satisfaction_class: RadrootsTransportSatisfactionClass, - ) -> usize { - self.target_receipts - .iter() - .filter(|receipt| receipt.status.counts_as_satisfied(satisfaction_class)) - .count() - } - - pub fn is_satisfied_by( - &self, - policy: &RadrootsTransportSatisfactionPolicy, - ) -> Result<bool, RadrootsTransportError> { - policy.validate_for_target_set(&self.target_set)?; - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => Ok(true), - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => { - policy.is_satisfied_by(self.target_set.len(), self.satisfied_target_count(*class)) - } - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => { - validate_required_targets(targets)?; - Ok(targets.iter().all(|required| { - self.target_receipts.iter().any(|receipt| { - receipt.target.fingerprint() == required - && receipt.status.counts_as_satisfied(*class) - }) - })) - } - } - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct RadrootsTransportDeliveryReceiptWire { - request_id: String, - target_set: TargetSet, - target_receipts: Vec<RadrootsTransportTargetReceipt>, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportDeliveryReceipt { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = RadrootsTransportDeliveryReceiptWire::deserialize(deserializer)?; - Self::new(wire.request_id, wire.target_set, wire.target_receipts) - .map_err(serde::de::Error::custom) - } -} diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -3,10 +3,8 @@ use core::fmt; /// Transport contract failure. -pub type Error = RadrootsTransportError; - #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTransportError { +pub enum Error { UnsupportedOperation, EmptyTransportKind, InvalidTransportKind, @@ -64,7 +62,7 @@ pub enum RadrootsTransportError { PayloadDigestMismatch, } -impl fmt::Display for RadrootsTransportError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedOperation => f.write_str("transport operation is unsupported"), diff --git a/crates/transport/src/id.rs b/crates/transport/src/id.rs @@ -1,4 +1,4 @@ -use crate::RadrootsTransportError; +use crate::Error as TransportError; use alloc::string::{String, ToString}; use core::{fmt, str::FromStr}; use radroots_protocol::capability::v1::{ @@ -26,14 +26,14 @@ impl TransportId { pub const RADROOTSD: Self = Self(ProtocolTransportKind::RADROOTSD); /// Parses an exact canonical identity. - pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse(value: impl AsRef<str>) -> Result<Self, TransportError> { ProtocolTransportKind::parse(value.as_ref()) .map(Self) .map_err(map_protocol_error) } /// Parses an exact canonical identity. - pub fn parse_canonical(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse_canonical(value: impl AsRef<str>) -> Result<Self, TransportError> { Self::parse(value) } @@ -48,11 +48,11 @@ impl TransportId { } } -fn map_protocol_error(error: ProtocolError) -> RadrootsTransportError { +fn map_protocol_error(error: ProtocolError) -> TransportError { match error { - ProtocolError::EmptyTransportKind => RadrootsTransportError::EmptyTransportKind, - ProtocolError::InvalidTransportKind { .. } => RadrootsTransportError::InvalidTransportKind, - _ => RadrootsTransportError::InvalidTransportKind, + ProtocolError::EmptyTransportKind => TransportError::EmptyTransportKind, + ProtocolError::InvalidTransportKind { .. } => TransportError::InvalidTransportKind, + _ => TransportError::InvalidTransportKind, } } @@ -69,7 +69,7 @@ impl fmt::Display for TransportId { } impl FromStr for TransportId { - type Err = RadrootsTransportError; + type Err = TransportError; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -77,7 +77,7 @@ impl FromStr for TransportId { } impl TryFrom<&str> for TransportId { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: &str) -> Result<Self, Self::Error> { Self::parse(value) @@ -85,7 +85,7 @@ impl TryFrom<&str> for TransportId { } impl TryFrom<String> for TransportId { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: String) -> Result<Self, Self::Error> { Self::parse(value) @@ -145,11 +145,11 @@ mod tests { assert_eq!(TransportId::from(protocol_id), id); assert_eq!( TransportId::parse(""), - Err(RadrootsTransportError::EmptyTransportKind) + Err(TransportError::EmptyTransportKind) ); assert_eq!( TransportId::parse("Invalid"), - Err(RadrootsTransportError::InvalidTransportKind) + Err(TransportError::InvalidTransportKind) ); #[cfg(feature = "serde")] diff --git a/crates/transport/src/kind.rs b/crates/transport/src/kind.rs @@ -1,13 +0,0 @@ -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportImplementationState { - Real, - Mock, -} - -#[doc(hidden)] -pub type RadrootsTransportCapabilityMaturity = crate::capability::Maturity; - -#[doc(hidden)] -pub type RadrootsTransportCapabilityAvailability = crate::capability::Availability; diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -6,48 +6,24 @@ extern crate alloc; pub mod capability; -mod delivery; pub mod endpoint; pub mod error; mod id; -mod kind; pub mod outcome; -mod payload; pub mod policy; pub mod sink; pub mod source; mod status; pub mod target; -mod transport; -pub use delivery::{ - RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RadrootsTransportDeliveryReceipt, - RadrootsTransportDeliveryRequest, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetReceipt, -}; -pub use error::{Error, RadrootsTransportError}; +pub use error::Error; pub use id::{TRANSPORT_ID_MAX_BYTES, TransportId}; -pub use kind::{ - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportImplementationState, -}; -pub use payload::RadrootsTransportPayload; pub use sink::{DeliveryReceipt, DeliveryRequest, EventSink, SinkFailure, SinkStatus}; pub use source::{BoxFuture, EventSource, FetchPage, FetchRequest, SourceStatus}; -pub use status::{ - RadrootsTransportCapabilities, RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportStatus, -}; pub use target::{TARGET_SET_MAX_ITEMS, Target, TargetSet}; -pub use transport::{RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest}; #[cfg(test)] extern crate self as radroots_transport; #[cfg(test)] extern crate std; - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -#[path = "../tests/transport.rs"] -mod tests; diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -1,332 +0,0 @@ -use crate::RadrootsTransportError; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use sha2::{Digest, Sha256}; - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTransportPayload { - SignedEventJson { - event_id: String, - raw_json: String, - digest: String, - }, - MeshFrameCbor { - message_id: String, - bytes: Vec<u8>, - digest: String, - }, - OpaqueBytes { - label: String, - bytes: Vec<u8>, - digest: String, - }, -} - -impl RadrootsTransportPayload { - pub fn unchecked_signed_event_json( - event_id: impl AsRef<str>, - raw_json: impl AsRef<str>, - ) -> Result<Self, RadrootsTransportError> { - Self::signed_event_json(event_id.as_ref(), raw_json.as_ref()) - } - - fn signed_event_json(event_id: &str, raw_json: &str) -> Result<Self, RadrootsTransportError> { - let event_id = validate_hex_id(event_id)?; - let raw_json = validate_raw_json(raw_json)?; - let digest = sha256_hex(raw_json.as_bytes()); - Ok(Self::SignedEventJson { - event_id, - raw_json, - digest, - }) - } - - pub fn unchecked_signed_event_json_with_digest( - event_id: impl AsRef<str>, - raw_json: impl AsRef<str>, - digest: impl AsRef<str>, - ) -> Result<Self, RadrootsTransportError> { - Self::signed_event_json_with_digest(event_id.as_ref(), raw_json.as_ref(), digest.as_ref()) - } - - fn signed_event_json_with_digest( - event_id: &str, - raw_json: &str, - digest: &str, - ) -> Result<Self, RadrootsTransportError> { - let payload = Self::signed_event_json(event_id, raw_json)?; - validate_supplied_digest(payload.digest(), digest)?; - Ok(payload) - } - - pub fn mesh_frame_cbor( - message_id: impl AsRef<str>, - bytes: impl AsRef<[u8]>, - ) -> Result<Self, RadrootsTransportError> { - Self::validated_mesh_frame_cbor(message_id.as_ref(), bytes.as_ref()) - } - - fn validated_mesh_frame_cbor( - message_id: &str, - bytes: &[u8], - ) -> Result<Self, RadrootsTransportError> { - let message_id = validate_token_id(message_id)?; - let bytes = validate_bytes(bytes)?; - let digest = sha256_hex(bytes.as_slice()); - Ok(Self::MeshFrameCbor { - message_id, - bytes, - digest, - }) - } - - pub fn mesh_frame_cbor_with_digest( - message_id: impl AsRef<str>, - bytes: impl AsRef<[u8]>, - digest: impl AsRef<str>, - ) -> Result<Self, RadrootsTransportError> { - Self::validated_mesh_frame_cbor_with_digest( - message_id.as_ref(), - bytes.as_ref(), - digest.as_ref(), - ) - } - - fn validated_mesh_frame_cbor_with_digest( - message_id: &str, - bytes: &[u8], - digest: &str, - ) -> Result<Self, RadrootsTransportError> { - let payload = Self::validated_mesh_frame_cbor(message_id, bytes)?; - validate_supplied_digest(payload.digest(), digest)?; - Ok(payload) - } - - pub fn opaque_bytes( - label: impl AsRef<str>, - bytes: impl AsRef<[u8]>, - ) -> Result<Self, RadrootsTransportError> { - Self::validated_opaque_bytes(label.as_ref(), bytes.as_ref()) - } - - fn validated_opaque_bytes(label: &str, bytes: &[u8]) -> Result<Self, RadrootsTransportError> { - let label = validate_label(label)?; - let bytes = validate_bytes(bytes)?; - let digest = sha256_hex(bytes.as_slice()); - Ok(Self::OpaqueBytes { - label, - bytes, - digest, - }) - } - - pub fn opaque_bytes_with_digest( - label: impl AsRef<str>, - bytes: impl AsRef<[u8]>, - digest: impl AsRef<str>, - ) -> Result<Self, RadrootsTransportError> { - Self::validated_opaque_bytes_with_digest(label.as_ref(), bytes.as_ref(), digest.as_ref()) - } - - fn validated_opaque_bytes_with_digest( - label: &str, - bytes: &[u8], - digest: &str, - ) -> Result<Self, RadrootsTransportError> { - let payload = Self::validated_opaque_bytes(label, bytes)?; - validate_supplied_digest(payload.digest(), digest)?; - Ok(payload) - } - - pub fn digest(&self) -> &str { - match self { - Self::SignedEventJson { digest, .. } - | Self::MeshFrameCbor { digest, .. } - | Self::OpaqueBytes { digest, .. } => digest.as_str(), - } - } - - pub fn payload_kind(&self) -> &'static str { - match self { - Self::SignedEventJson { .. } => "signed_event_json", - Self::MeshFrameCbor { .. } => "mesh_frame_cbor", - Self::OpaqueBytes { .. } => "opaque_bytes", - } - } - - pub fn validate(&self) -> Result<(), RadrootsTransportError> { - let canonical = match self { - Self::SignedEventJson { - event_id, - raw_json, - digest, - } => Self::signed_event_json_with_digest(event_id, raw_json, digest)?, - Self::MeshFrameCbor { - message_id, - bytes, - digest, - } => Self::validated_mesh_frame_cbor_with_digest(message_id, bytes, digest)?, - Self::OpaqueBytes { - label, - bytes, - digest, - } => Self::validated_opaque_bytes_with_digest(label, bytes, digest)?, - }; - if &canonical != self { - return Err(RadrootsTransportError::InvalidPayloadLabel); - } - Ok(()) - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -enum RadrootsTransportPayloadWire { - SignedEventJson { - event_id: String, - raw_json: String, - digest: String, - }, - MeshFrameCbor { - message_id: String, - bytes: Vec<u8>, - digest: String, - }, - OpaqueBytes { - label: String, - bytes: Vec<u8>, - digest: String, - }, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportPayload { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = RadrootsTransportPayloadWire::deserialize(deserializer)?; - let payload = match wire { - RadrootsTransportPayloadWire::SignedEventJson { - event_id, - raw_json, - digest, - } => Self::SignedEventJson { - event_id, - raw_json, - digest, - }, - RadrootsTransportPayloadWire::MeshFrameCbor { - message_id, - bytes, - digest, - } => Self::MeshFrameCbor { - message_id, - bytes, - digest, - }, - RadrootsTransportPayloadWire::OpaqueBytes { - label, - bytes, - digest, - } => Self::OpaqueBytes { - label, - bytes, - digest, - }, - }; - payload.validate().map_err(serde::de::Error::custom)?; - Ok(payload) - } -} - -fn validate_hex_id(raw: &str) -> Result<String, RadrootsTransportError> { - if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(RadrootsTransportError::InvalidPayloadId); - } - let lowered = raw.to_ascii_lowercase(); - if raw != lowered { - return Err(RadrootsTransportError::InvalidPayloadId); - } - Ok(lowered) -} - -fn validate_token_id(raw: &str) -> Result<String, RadrootsTransportError> { - if raw.is_empty() { - return Err(RadrootsTransportError::EmptyPayloadId); - } - if raw != raw.trim() - || raw - .chars() - .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))) - { - return Err(RadrootsTransportError::InvalidPayloadId); - } - Ok(raw.to_string()) -} - -fn validate_label(raw: &str) -> Result<String, RadrootsTransportError> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(RadrootsTransportError::EmptyPayloadLabel); - } - if trimmed.chars().any(char::is_control) { - return Err(RadrootsTransportError::InvalidPayloadLabel); - } - Ok(trimmed.to_string()) -} - -fn validate_raw_json(raw: &str) -> Result<String, RadrootsTransportError> { - if raw.is_empty() { - return Err(RadrootsTransportError::EmptyPayloadBytes); - } - if raw != raw.trim() - || raw.chars().any(char::is_control) - || !raw.starts_with('{') - || !raw.ends_with('}') - { - return Err(RadrootsTransportError::InvalidPayloadBytes); - } - Ok(raw.to_string()) -} - -fn validate_bytes(raw: &[u8]) -> Result<Vec<u8>, RadrootsTransportError> { - if raw.is_empty() { - return Err(RadrootsTransportError::EmptyPayloadBytes); - } - Ok(raw.to_vec()) -} - -fn validate_supplied_digest(expected: &str, supplied: &str) -> Result<(), RadrootsTransportError> { - validate_digest(supplied)?; - if supplied != expected { - return Err(RadrootsTransportError::PayloadDigestMismatch); - } - Ok(()) -} - -fn validate_digest(raw: &str) -> Result<(), RadrootsTransportError> { - if raw.len() != 64 - || !raw - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - { - return Err(RadrootsTransportError::InvalidPayloadDigest); - } - Ok(()) -} - -fn sha256_hex(bytes: &[u8]) -> String { - hex_encode(Sha256::digest(bytes).as_slice()) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -1,8 +1,6 @@ use crate::{ - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportImplementationState, TransportId, + TransportId, capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities}, - delivery::RadrootsTransportSatisfactionClass, }; use alloc::string::String; use radroots_protocol::capability::v1::TransportDescriptor; @@ -202,355 +200,3 @@ impl SinkStatus { self.capabilities } } - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportDeliveryTargetStatus { - Pending, - Accepted, - Delivered, - Forwarded, - StoredByGateway, - Seen, - DeferredUntilImplemented, - SkippedPolicyDenied, - FailedRetryable, - FailedTerminal, -} - -impl RadrootsTransportDeliveryTargetStatus { - pub fn is_ready_for_attempt(self) -> bool { - matches!(self, Self::Pending | Self::FailedRetryable) - } - - pub fn counts_as_accepted_satisfaction(self) -> bool { - matches!( - self, - Self::Accepted | Self::Delivered | Self::Forwarded | Self::StoredByGateway | Self::Seen - ) - } - - pub fn counts_as_delivered_satisfaction(self) -> bool { - matches!(self, Self::Delivered) - } - - pub fn counts_as_satisfied( - self, - satisfaction_class: RadrootsTransportSatisfactionClass, - ) -> bool { - match satisfaction_class { - RadrootsTransportSatisfactionClass::Accepted => self.counts_as_accepted_satisfaction(), - RadrootsTransportSatisfactionClass::Forwarded => { - matches!(self, Self::Forwarded | Self::Delivered) - } - RadrootsTransportSatisfactionClass::Stored => matches!(self, Self::StoredByGateway), - RadrootsTransportSatisfactionClass::Seen => { - matches!(self, Self::Seen | Self::Delivered) - } - RadrootsTransportSatisfactionClass::Delivered => { - self.counts_as_delivered_satisfaction() - } - RadrootsTransportSatisfactionClass::DurableOrObserved => { - matches!(self, Self::StoredByGateway | Self::Seen | Self::Delivered) - } - } - } - - pub fn is_retryable_failure(self) -> bool { - matches!(self, Self::FailedRetryable) - } - - pub fn is_terminal_failure(self) -> bool { - matches!(self, Self::SkippedPolicyDenied | Self::FailedTerminal) - } - - pub fn is_deferred_until_implemented(self) -> bool { - matches!(self, Self::DeferredUntilImplemented) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportOutcomeKind { - Accepted, - DuplicateAccepted, - Delivered, - Forwarded, - StoredByGateway, - Seen, - DeferredUntilImplemented, - Rejected, - RouteUnavailable, - PayloadTooLarge, - PolicyDenied, - Timeout, - ConnectionFailed, - TransportUnavailable, -} - -impl RadrootsTransportOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Accepted => "accepted", - Self::DuplicateAccepted => "duplicate_accepted", - Self::Delivered => "delivered", - Self::Forwarded => "forwarded", - Self::StoredByGateway => "stored_by_gateway", - Self::Seen => "seen", - Self::DeferredUntilImplemented => "deferred_until_implemented", - Self::Rejected => "rejected", - Self::RouteUnavailable => "route_unavailable", - Self::PayloadTooLarge => "payload_too_large", - Self::PolicyDenied => "policy_denied", - Self::Timeout => "timeout", - Self::ConnectionFailed => "connection_failed", - Self::TransportUnavailable => "transport_unavailable", - } - } - - pub fn target_status(self) -> RadrootsTransportDeliveryTargetStatus { - match self { - Self::Accepted | Self::DuplicateAccepted => { - RadrootsTransportDeliveryTargetStatus::Accepted - } - Self::Delivered => RadrootsTransportDeliveryTargetStatus::Delivered, - Self::Forwarded => RadrootsTransportDeliveryTargetStatus::Forwarded, - Self::StoredByGateway => RadrootsTransportDeliveryTargetStatus::StoredByGateway, - Self::Seen => RadrootsTransportDeliveryTargetStatus::Seen, - Self::DeferredUntilImplemented => { - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - } - Self::PolicyDenied => RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied, - Self::Timeout | Self::ConnectionFailed | Self::TransportUnavailable => { - RadrootsTransportDeliveryTargetStatus::FailedRetryable - } - Self::Rejected | Self::RouteUnavailable | Self::PayloadTooLarge => { - RadrootsTransportDeliveryTargetStatus::FailedTerminal - } - } - } - - pub fn counts_as_satisfied( - self, - satisfaction_class: RadrootsTransportSatisfactionClass, - ) -> bool { - self.target_status().counts_as_satisfied(satisfaction_class) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportOutcome { - pub kind: RadrootsTransportOutcomeKind, - pub status: RadrootsTransportDeliveryTargetStatus, - pub code: Option<String>, - pub message: Option<String>, -} - -impl RadrootsTransportOutcome { - pub fn new(kind: RadrootsTransportOutcomeKind) -> Self { - Self { - kind, - status: kind.target_status(), - code: None, - message: None, - } - } - - pub fn with_message(mut self, message: impl Into<String>) -> Self { - self.message = Some(message.into()); - self - } - - pub fn with_code(mut self, code: impl Into<String>) -> Self { - self.code = Some(code.into()); - self - } - - pub fn validate(&self) -> Result<(), crate::RadrootsTransportError> { - if self.status != self.kind.target_status() { - return Err(crate::RadrootsTransportError::TransportOutcomeStatusMismatch); - } - Ok(()) - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct RadrootsTransportOutcomeWire { - kind: RadrootsTransportOutcomeKind, - status: RadrootsTransportDeliveryTargetStatus, - code: Option<String>, - message: Option<String>, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for RadrootsTransportOutcome { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = RadrootsTransportOutcomeWire::deserialize(deserializer)?; - let outcome = Self { - kind: wire.kind, - status: wire.status, - code: wire.code, - message: wire.message, - }; - outcome.validate().map_err(serde::de::Error::custom)?; - Ok(outcome) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportCapabilities { - pub deliver: bool, - pub fetch: bool, - pub discovery: bool, - pub gateway_forwarding: bool, - pub receipt_observation: bool, -} - -impl RadrootsTransportCapabilities { - pub const fn none() -> Self { - Self { - deliver: false, - fetch: false, - discovery: false, - gateway_forwarding: false, - receipt_observation: false, - } - } - - pub const fn deliver_only() -> Self { - Self { - deliver: true, - fetch: false, - discovery: false, - gateway_forwarding: false, - receipt_observation: false, - } - } - - pub const fn fetch_only() -> Self { - Self { - deliver: false, - fetch: true, - discovery: false, - gateway_forwarding: false, - receipt_observation: false, - } - } - - pub const fn deliver_and_fetch() -> Self { - Self { - deliver: true, - fetch: true, - discovery: false, - gateway_forwarding: false, - receipt_observation: false, - } - } - - pub const fn with_discovery(mut self, discovery: bool) -> Self { - self.discovery = discovery; - self - } - - pub const fn with_gateway_forwarding(mut self, gateway_forwarding: bool) -> Self { - self.gateway_forwarding = gateway_forwarding; - self - } - - pub const fn with_receipt_observation(mut self, receipt_observation: bool) -> Self { - self.receipt_observation = receipt_observation; - self - } - - pub const fn reticulum_unavailable() -> Self { - Self { - deliver: false, - fetch: false, - discovery: false, - gateway_forwarding: false, - receipt_observation: false, - } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportStatus { - #[cfg_attr(feature = "serde", serde(rename = "transport"))] - pub kind: TransportId, - pub profile_id: Option<String>, - pub endpoint_uri: Option<String>, - pub configured: bool, - pub implementation: RadrootsTransportImplementationState, - pub maturity: RadrootsTransportCapabilityMaturity, - pub availability: RadrootsTransportCapabilityAvailability, - pub usable_for_delivery: bool, - pub capabilities: RadrootsTransportCapabilities, - pub message: String, -} - -impl RadrootsTransportStatus { - pub fn new( - kind: TransportId, - configured: bool, - implementation: RadrootsTransportImplementationState, - usable_for_delivery: bool, - message: impl Into<String>, - ) -> Self { - Self { - kind, - profile_id: None, - endpoint_uri: None, - configured, - implementation, - maturity: RadrootsTransportCapabilityMaturity::Stable, - availability: if usable_for_delivery { - RadrootsTransportCapabilityAvailability::Available - } else { - RadrootsTransportCapabilityAvailability::Unavailable - }, - usable_for_delivery, - capabilities: if usable_for_delivery { - RadrootsTransportCapabilities::deliver_only() - } else { - RadrootsTransportCapabilities::none() - }, - message: message.into(), - } - } - - pub fn with_capabilities(mut self, capabilities: RadrootsTransportCapabilities) -> Self { - self.capabilities = capabilities; - self - } - - pub fn with_maturity(mut self, maturity: RadrootsTransportCapabilityMaturity) -> Self { - self.maturity = maturity; - self - } - - pub fn with_availability( - mut self, - availability: RadrootsTransportCapabilityAvailability, - ) -> Self { - self.availability = availability; - self - } - - pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self { - self.profile_id = Some(profile_id.into()); - self - } - - pub fn with_endpoint_uri(mut self, endpoint_uri: impl Into<String>) -> Self { - self.endpoint_uri = Some(endpoint_uri.into()); - self - } -} diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -1,7 +1,7 @@ //! Extensible transport identities and canonical operation targets. use crate::{ - RadrootsTransportError, TransportId, + Error as TransportError, TransportId, endpoint::{ENDPOINT_URI_MAX_BYTES, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES}, }; use alloc::collections::BTreeSet; @@ -21,12 +21,12 @@ pub const TARGET_SET_MAX_ITEMS: usize = 64; pub struct EndpointUri(String); impl EndpointUri { - pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> { let canonical = canonicalize_uri(raw.as_ref())?; Ok(Self(canonical)) } - fn parse_nostr_relay(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + fn parse_nostr_relay(raw: impl AsRef<str>) -> Result<Self, TransportError> { let canonical = canonicalize_nostr_relay_uri(raw.as_ref())?; Ok(Self(canonical)) } @@ -49,7 +49,7 @@ impl AsRef<str> for EndpointUri { } impl FromStr for EndpointUri { - type Err = RadrootsTransportError; + type Err = TransportError; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -57,7 +57,7 @@ impl FromStr for EndpointUri { } impl TryFrom<&str> for EndpointUri { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: &str) -> Result<Self, Self::Error> { Self::parse(value) @@ -87,10 +87,10 @@ impl<'de> serde::Deserialize<'de> for EndpointUri { pub struct TargetScope(String); impl TargetScope { - pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> { let value = raw.as_ref(); if value.is_empty() { - return Err(RadrootsTransportError::EmptyTargetScope); + return Err(TransportError::EmptyTargetScope); } if value.len() > TARGET_SCOPE_MAX_BYTES || value != value.trim() @@ -98,7 +98,7 @@ impl TargetScope { .chars() .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))) { - return Err(RadrootsTransportError::InvalidTargetScope); + return Err(TransportError::InvalidTargetScope); } Ok(Self(value.to_string())) } @@ -121,7 +121,7 @@ impl AsRef<str> for TargetScope { } impl FromStr for TargetScope { - type Err = RadrootsTransportError; + type Err = TransportError; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -129,7 +129,7 @@ impl FromStr for TargetScope { } impl TryFrom<&str> for TargetScope { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: &str) -> Result<Self, Self::Error> { Self::parse(value) @@ -153,14 +153,14 @@ impl<'de> serde::Deserialize<'de> for TargetScope { pub struct TargetLabel(String); impl TargetLabel { - pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> { let raw = raw.as_ref(); let trimmed = raw.trim(); if trimmed.is_empty() { - return Err(RadrootsTransportError::EmptyTargetLabel); + return Err(TransportError::EmptyTargetLabel); } if raw.len() > TARGET_LABEL_MAX_BYTES || trimmed.chars().any(char::is_control) { - return Err(RadrootsTransportError::InvalidTargetLabel); + return Err(TransportError::InvalidTargetLabel); } Ok(Self(trimmed.to_string())) } @@ -183,7 +183,7 @@ impl AsRef<str> for TargetLabel { } impl FromStr for TargetLabel { - type Err = RadrootsTransportError; + type Err = TransportError; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -191,7 +191,7 @@ impl FromStr for TargetLabel { } impl TryFrom<&str> for TargetLabel { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: &str) -> Result<Self, Self::Error> { Self::parse(value) @@ -234,10 +234,10 @@ impl TargetFingerprint { Self(hex_encode(&digest)) } - pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> { let raw = raw.as_ref(); if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(RadrootsTransportError::InvalidTargetFingerprint); + return Err(TransportError::InvalidTargetFingerprint); } Ok(Self(raw.to_ascii_lowercase())) } @@ -270,7 +270,7 @@ impl AsRef<str> for TargetFingerprint { } impl FromStr for TargetFingerprint { - type Err = RadrootsTransportError; + type Err = TransportError; fn from_str(value: &str) -> Result<Self, Self::Err> { Self::parse(value) @@ -278,7 +278,7 @@ impl FromStr for TargetFingerprint { } impl TryFrom<&str> for TargetFingerprint { - type Error = RadrootsTransportError; + type Error = TransportError; fn try_from(value: &str) -> Result<Self, Self::Error> { Self::parse(value) @@ -314,11 +314,11 @@ pub struct Target { } impl Target { - pub fn new(kind: TransportId, uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn new(kind: TransportId, uri: impl AsRef<str>) -> Result<Self, TransportError> { Self::new_with_metadata(kind, uri, None, None) } - pub fn nostr_relay(uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn nostr_relay(uri: impl AsRef<str>) -> Result<Self, TransportError> { Self::nostr_relay_with_metadata(uri, None, None) } @@ -326,11 +326,11 @@ impl Target { uri: impl AsRef<str>, scope: Option<TargetScope>, label: Option<TargetLabel>, - ) -> Result<Self, RadrootsTransportError> { + ) -> Result<Self, TransportError> { Self::new_with_metadata(TransportId::NOSTR, uri, scope, label) } - pub fn local(uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + pub fn local(uri: impl AsRef<str>) -> Result<Self, TransportError> { Self::local_with_metadata(uri, None, None) } @@ -338,7 +338,7 @@ impl Target { uri: impl AsRef<str>, scope: Option<TargetScope>, label: Option<TargetLabel>, - ) -> Result<Self, RadrootsTransportError> { + ) -> Result<Self, TransportError> { Self::new_with_metadata(TransportId::LOCAL, uri, scope, label) } @@ -347,7 +347,7 @@ impl Target { uri: impl AsRef<str>, scope: Option<TargetScope>, label: Option<TargetLabel>, - ) -> Result<Self, RadrootsTransportError> { + ) -> Result<Self, TransportError> { let raw_uri = uri.as_ref(); let uri = match kind { TransportId::NOSTR => EndpointUri::parse_nostr_relay(raw_uri)?, @@ -412,7 +412,7 @@ impl<'de> serde::Deserialize<'de> for Target { .map(|label| { let parsed = TargetLabel::parse(label.as_str())?; if parsed.as_str() != label { - return Err(RadrootsTransportError::InvalidTargetLabel); + return Err(TransportError::InvalidTargetLabel); } Ok(parsed) }) @@ -449,17 +449,17 @@ pub struct TargetSet { } impl TargetSet { - pub fn new(targets: Vec<Target>) -> Result<Self, RadrootsTransportError> { + pub fn new(targets: Vec<Target>) -> Result<Self, TransportError> { if targets.is_empty() { - return Err(RadrootsTransportError::EmptyTargetSet); + return Err(TransportError::EmptyTargetSet); } if targets.len() > TARGET_SET_MAX_ITEMS { - return Err(RadrootsTransportError::TargetSetTooLarge); + return Err(TransportError::TargetSetTooLarge); } let mut fingerprints = BTreeSet::new(); for target in &targets { if !fingerprints.insert(target.fingerprint.as_str().to_string()) { - return Err(RadrootsTransportError::DuplicateTargetFingerprint); + return Err(TransportError::DuplicateTargetFingerprint); } } Ok(Self { targets }) @@ -503,27 +503,27 @@ impl<'de> serde::Deserialize<'de> for TargetSet { } } -fn canonicalize_uri(raw: &str) -> Result<String, RadrootsTransportError> { +fn canonicalize_uri(raw: &str) -> Result<String, TransportError> { let trimmed = raw.trim(); if trimmed.is_empty() { - return Err(RadrootsTransportError::EmptyTargetUri); + return Err(TransportError::EmptyTargetUri); } if raw != trimmed { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if trimmed.len() > ENDPOINT_URI_MAX_BYTES { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if trimmed .chars() .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if let Some(colon) = trimmed.find(':') { let scheme = &trimmed[..colon]; if !is_valid_scheme(scheme) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let rest = &trimmed[colon + 1..]; if let Some(authority_rest) = rest.strip_prefix("//") { @@ -550,32 +550,32 @@ fn is_valid_scheme(value: &str) -> bool { && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) } -fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, RadrootsTransportError> { +fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, TransportError> { let trimmed = raw.trim(); if trimmed.is_empty() { - return Err(RadrootsTransportError::EmptyTargetUri); + return Err(TransportError::EmptyTargetUri); } if raw != trimmed { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if trimmed.len() > ENDPOINT_URI_MAX_BYTES { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if trimmed .chars() .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } if trimmed.contains('?') || trimmed.contains('#') || trimmed.contains('\\') { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let Some(scheme_end) = trimmed.find("://") else { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); }; let scheme = trimmed[..scheme_end].to_ascii_lowercase(); if !matches!(scheme.as_str(), "wss" | "ws") { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let endpoint = &trimmed[scheme_end + 3..]; let authority_end = endpoint.find('/').unwrap_or(endpoint.len()); @@ -592,13 +592,13 @@ fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, RadrootsTransportEr fn canonicalize_nostr_relay_authority( authority: &str, scheme: &str, -) -> Result<String, RadrootsTransportError> { +) -> Result<String, TransportError> { if authority.is_empty() || authority.contains('@') { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let (host, port) = if let Some(rest) = authority.strip_prefix('[') { let Some(host_end) = rest.find(']') else { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); }; let host = &rest[..host_end]; let suffix = &rest[host_end + 1..]; @@ -607,7 +607,7 @@ fn canonicalize_nostr_relay_authority( .chars() .any(|ch| matches!(ch, '[' | ']' | '/' | '?' | '#' | '@' | '\\')) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let canonical_host = canonicalize_nostr_relay_ipv6(host)?; ( @@ -616,7 +616,7 @@ fn canonicalize_nostr_relay_authority( ) } else { if authority.contains(['[', ']', '\\']) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let mut parts = authority.splitn(2, ':'); let host = parts.next().unwrap_or_default(); @@ -627,7 +627,7 @@ fn canonicalize_nostr_relay_authority( (canonicalize_nostr_relay_host(host)?, port) }; if scheme == "ws" && !is_local_ws_relay_host(host.as_str()) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let port = port.filter(|port| !matches!((scheme, port.as_str()), ("wss", "443") | ("ws", "80"))); @@ -637,51 +637,51 @@ fn canonicalize_nostr_relay_authority( }) } -fn parse_nostr_relay_port(suffix: &str) -> Result<Option<String>, RadrootsTransportError> { +fn parse_nostr_relay_port(suffix: &str) -> Result<Option<String>, TransportError> { if suffix.is_empty() { return Ok(None); } let Some(port) = suffix.strip_prefix(':') else { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); }; parse_nostr_relay_port_with_prefix(port).map(Some) } -fn parse_nostr_relay_port_with_prefix(port: &str) -> Result<String, RadrootsTransportError> { +fn parse_nostr_relay_port_with_prefix(port: &str) -> Result<String, TransportError> { if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) || port.len() > 1 && port.starts_with('0') { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let value = port .parse::<u32>() - .map_err(|_| RadrootsTransportError::InvalidTargetUri)?; + .map_err(|_| TransportError::InvalidTargetUri)?; if !(1..=u16::MAX as u32).contains(&value) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } Ok(port.to_string()) } -fn canonicalize_nostr_relay_host(host: &str) -> Result<String, RadrootsTransportError> { +fn canonicalize_nostr_relay_host(host: &str) -> Result<String, TransportError> { let canonical = host.to_ascii_lowercase(); if canonical_ipv4(canonical.as_str()) || canonical_dns_host(canonical.as_str()) { return Ok(canonical); } - Err(RadrootsTransportError::InvalidTargetUri) + Err(TransportError::InvalidTargetUri) } -fn canonicalize_nostr_relay_ipv6(host: &str) -> Result<String, RadrootsTransportError> { +fn canonicalize_nostr_relay_ipv6(host: &str) -> Result<String, TransportError> { if host.is_empty() || !host .bytes() .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f' | b'A'..=b'F' | b':')) { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } host.parse::<Ipv6Addr>() .map(|address| address.to_string()) - .map_err(|_| RadrootsTransportError::InvalidTargetUri) + .map_err(|_| TransportError::InvalidTargetUri) } fn canonical_ipv4(value: &str) -> bool { @@ -734,17 +734,17 @@ fn dns_label_is_whatwg_number(value: &str) -> bool { .is_some_and(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit())) } -fn validate_nostr_relay_path(path: &str) -> Result<(), RadrootsTransportError> { +fn validate_nostr_relay_path(path: &str) -> Result<(), TransportError> { if path.is_empty() || path == "/" { return Ok(()); } let Some(component) = path.strip_prefix('/') else { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); }; if relay_path_component_is_valid(component) { Ok(()) } else { - Err(RadrootsTransportError::InvalidTargetUri) + Err(TransportError::InvalidTargetUri) } } diff --git a/crates/transport/src/transport.rs b/crates/transport/src/transport.rs @@ -1,41 +0,0 @@ -use crate::{RadrootsTransportTargetReceipt, TargetSet}; -use alloc::string::String; -use alloc::vec::Vec; - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportFetchRequest { - pub request_id: String, - pub target_set: TargetSet, -} - -impl RadrootsTransportFetchRequest { - pub fn new(request_id: impl Into<String>, target_set: TargetSet) -> Self { - Self { - request_id: request_id.into(), - target_set, - } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTransportFetchReceipt { - pub request_id: String, - pub target_receipts: Vec<RadrootsTransportTargetReceipt>, - pub fetched_count: usize, -} - -impl RadrootsTransportFetchReceipt { - pub fn new( - request_id: impl Into<String>, - target_receipts: Vec<RadrootsTransportTargetReceipt>, - fetched_count: usize, - ) -> Self { - Self { - request_id: request_id.into(), - target_receipts, - fetched_count, - } - } -} diff --git a/crates/transport/tests/delivery_contract.rs b/crates/transport/tests/delivery_contract.rs @@ -305,6 +305,87 @@ fn canonical_evaluator_covers_pending_exhausted_and_historical_evidence() { } #[test] +fn policy_introspection_validation_and_wire_variants_are_complete() { + let set = targets(); + let any = TargetPolicy::any(); + assert!(any.is_any()); + assert!(!any.is_all()); + assert_eq!(any.quorum_threshold(), None); + assert_eq!(any.required_targets(), None); + + let all = TargetPolicy::all(); + assert!(!all.is_any()); + assert!(all.is_all()); + assert_eq!(all.quorum_threshold(), None); + assert_eq!(all.required_targets(), None); + + let quorum = TargetPolicy::quorum(2).expect("quorum"); + assert!(!quorum.is_any()); + assert!(!quorum.is_all()); + assert_eq!(quorum.quorum_threshold(), Some(2)); + assert_eq!(quorum.required_targets(), None); + + let fingerprint = set.targets()[0].fingerprint().clone(); + let required = TargetPolicy::required(vec![fingerprint.clone()]).expect("required"); + assert!(!required.is_any()); + assert!(!required.is_all()); + assert_eq!(required.quorum_threshold(), None); + assert_eq!( + required.required_targets(), + Some(core::slice::from_ref(&fingerprint)) + ); + + let policy = SatisfactionPolicy::new(SatisfactionClass::Delivered, required); + assert_eq!(policy.class(), SatisfactionClass::Delivered); + assert_eq!( + policy.targets().required_targets(), + Some(core::slice::from_ref(&fingerprint)) + ); + policy + .validate_for(&set) + .expect("required target is requested"); + + let foreign = Target::nostr_relay("wss://foreign.example").expect("foreign"); + let impossible = SatisfactionPolicy::new( + SatisfactionClass::Accepted, + TargetPolicy::required(vec![foreign.fingerprint().clone()]).expect("required"), + ); + assert_eq!( + impossible.validate_for(&set).unwrap_err(), + Error::RequiredTargetNotRequested + ); + + #[cfg(feature = "serde")] + for (wire, assertion) in [ + (serde_json::json!("any"), 0_u8), + (serde_json::json!("all"), 1), + (serde_json::json!({"quorum": 2}), 2), + (serde_json::json!({"required": [fingerprint.as_str()]}), 3), + ] { + let decoded: TargetPolicy = serde_json::from_value(wire).expect("target policy"); + match assertion { + 0 => assert!(decoded.is_any()), + 1 => assert!(decoded.is_all()), + 2 => assert_eq!(decoded.quorum_threshold(), Some(2)), + 3 => assert_eq!( + decoded.required_targets(), + Some(core::slice::from_ref(&fingerprint)) + ), + _ => unreachable!(), + } + } + + #[cfg(feature = "serde")] + for invalid in [ + serde_json::json!({"quorum": 0}), + serde_json::json!({"required": []}), + serde_json::json!({"required": [fingerprint.as_str(), fingerprint.as_str()]}), + ] { + assert!(serde_json::from_value::<TargetPolicy>(invalid).is_err()); + } +} + +#[test] fn sink_failures_retain_validated_retry_timing_and_partial_evidence() { let request = request(SatisfactionPolicy::new( SatisfactionClass::Accepted, diff --git a/crates/transport/tests/error_contract.rs b/crates/transport/tests/error_contract.rs @@ -0,0 +1,195 @@ +use radroots_transport::Error; + +#[test] +fn every_transport_error_has_stable_operator_facing_text() { + let cases = [ + ( + Error::UnsupportedOperation, + "transport operation is unsupported", + ), + (Error::EmptyTransportKind, "transport kind is empty"), + (Error::InvalidTransportKind, "transport kind is invalid"), + (Error::EmptyTargetUri, "transport target URI is empty"), + (Error::InvalidTargetUri, "transport target URI is invalid"), + (Error::EmptyTargetScope, "transport target scope is empty"), + ( + Error::InvalidTargetScope, + "transport target scope is invalid", + ), + (Error::EmptyTargetLabel, "transport target label is empty"), + ( + Error::InvalidTargetLabel, + "transport target label is invalid", + ), + (Error::EmptyTargetSet, "transport target set is empty"), + ( + Error::TargetSetTooLarge, + "transport target set exceeds its item limit", + ), + ( + Error::DuplicateTargetFingerprint, + "transport target set contains duplicate fingerprints", + ), + ( + Error::InvalidTargetFingerprint, + "transport target fingerprint is invalid", + ), + ( + Error::EmptyFetchRequestId, + "transport fetch request id is empty", + ), + ( + Error::InvalidFetchRequestId, + "transport fetch request id is invalid", + ), + (Error::InvalidFetchLimit, "transport fetch limit is invalid"), + ( + Error::InvalidFetchDeadline, + "transport fetch deadline is invalid", + ), + ( + Error::FetchSelectorTooLarge, + "transport fetch selector exceeds its item limit", + ), + ( + Error::DuplicateFetchKind, + "transport fetch selector contains a duplicate event kind", + ), + ( + Error::DuplicateFetchAuthor, + "transport fetch selector contains a duplicate author", + ), + ( + Error::InvalidFetchTimeRange, + "transport fetch selector time range is invalid", + ), + (Error::EmptyFetchCursor, "transport fetch cursor is empty"), + ( + Error::InvalidFetchCursor, + "transport fetch cursor is invalid", + ), + ( + Error::InvalidObservedAt, + "transport event observation time is invalid", + ), + ( + Error::UnexpectedFetchEvent, + "transport fetch page contains an event outside its selector", + ), + ( + Error::UnexpectedFetchProvenance, + "transport fetch page contains unexpected event provenance", + ), + ( + Error::UnexpectedFetchTargetOutcome, + "transport fetch page contains an unexpected target outcome", + ), + ( + Error::DuplicateFetchTargetOutcome, + "transport fetch page contains a duplicate target outcome", + ), + ( + Error::FetchPageLimitExceeded, + "transport fetch page exceeds its requested event limit", + ), + ( + Error::FetchPageRequestMismatch, + "transport fetch page does not match its request", + ), + ( + Error::InvalidSatisfactionPolicy, + "transport satisfaction policy is invalid", + ), + ( + Error::EmptyRequiredTargetSet, + "transport required target set is empty", + ), + ( + Error::DuplicateRequiredTargetFingerprint, + "transport required target set contains duplicate fingerprints", + ), + ( + Error::RequiredTargetNotRequested, + "transport required target was not requested", + ), + ( + Error::EmptyDeliveryRequestId, + "transport delivery request id is empty", + ), + ( + Error::InvalidDeliveryRequestId, + "transport delivery request id is invalid", + ), + ( + Error::InvalidDeliveryTimestamp, + "transport delivery timestamp is invalid", + ), + ( + Error::InvalidDeliveryDeadline, + "transport delivery deadline is invalid", + ), + ( + Error::InvalidDeliveryOutcome, + "transport delivery outcome is invalid", + ), + ( + Error::UnexpectedDeliveryTargetReceipt, + "transport delivery receipt contains an unexpected target", + ), + ( + Error::DuplicateDeliveryTargetReceipt, + "transport delivery receipt contains a duplicate target", + ), + ( + Error::MissingDeliveryTargetReceipt, + "transport delivery receipt is missing a requested target", + ), + ( + Error::DeliveryTargetReceiptStatusMismatch, + "transport delivery target receipt status does not match its outcome", + ), + ( + Error::DeliveryTargetReceiptAttemptMismatch, + "transport delivery target receipt attempt evidence is incoherent", + ), + ( + Error::TransportOutcomeStatusMismatch, + "transport outcome status does not match its outcome kind", + ), + ( + Error::DeliveryReceiptRequestIdMismatch, + "transport delivery receipt request id does not match its request", + ), + ( + Error::DeliveryReceiptTargetSetMismatch, + "transport delivery receipt target set does not match its request", + ), + (Error::EmptyPayloadId, "transport payload id is empty"), + (Error::InvalidPayloadId, "transport payload id is invalid"), + (Error::EmptyPayloadLabel, "transport payload label is empty"), + ( + Error::InvalidPayloadLabel, + "transport payload label is invalid", + ), + ( + Error::EmptyPayloadBytes, + "transport payload bytes are empty", + ), + ( + Error::InvalidPayloadBytes, + "transport payload bytes are invalid", + ), + ( + Error::InvalidPayloadDigest, + "transport payload digest is invalid", + ), + ( + Error::PayloadDigestMismatch, + "transport payload digest does not match payload bytes", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } +} diff --git a/crates/transport/tests/package_boundary.rs b/crates/transport/tests/package_boundary.rs @@ -16,7 +16,6 @@ const ROOT: &str = include_str!("../src/lib.rs"); const SOURCE: &str = include_str!("../src/source.rs"); const SINK: &str = include_str!("../src/sink.rs"); const ID: &str = include_str!("../src/id.rs"); -const PREDECESSOR_MODELS: &str = include_str!("../src/transport.rs"); const TARGET: &str = include_str!("../src/target.rs"); const DEVIATIONS: &str = include_str!("../../../docs/implementation/deviations.toml"); @@ -181,7 +180,6 @@ fn source_and_sink_are_independent_dyn_compatible_host_spis() { } assert!(!SINK.contains("fn fetch(")); - assert!(!PREDECESSOR_MODELS.contains("pub trait RadrootsTransport")); for retired in [ "Publish-frozen external-consumer", "pub fn reticulum(", diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -199,7 +199,7 @@ const FORBIDDEN_CORE_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ const FORBIDDEN_DELIVERY_PAYLOAD_CONCEPTS: &[ForbiddenConcept] = &[ ForbiddenConcept { pattern: "payload_digest", - reason: "delivery requests must carry RadrootsTransportPayload instead of digest-only fields", + reason: "delivery requests must carry DeliveryPayload instead of digest-only fields", }, ForbiddenConcept { pattern: "DigestOnly", @@ -641,7 +641,7 @@ fn transport_target_identity_sources_reject_silent_dedupe() { let transport_source = read_source(crates_root.join("transport/src/target.rs").as_path()); for required in [ "let mut fingerprints = BTreeSet::new();", - "RadrootsTransportError::DuplicateTargetFingerprint", + "TransportError::DuplicateTargetFingerprint", "targets.len() > TARGET_SET_MAX_ITEMS", ] { assert!( diff --git a/crates/transport/tests/source_contract.rs b/crates/transport/tests/source_contract.rs @@ -6,6 +6,7 @@ use std::sync::{ use futures::{future, task::noop_waker_ref}; use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; +use radroots_identity::PublicKey; use radroots_transport::{ BoxFuture, Error, EventSource, FetchPage, FetchRequest, SourceStatus, Target, TargetSet, TransportId, @@ -137,6 +138,69 @@ fn fetch_bounds_request_ids_and_cursors_fail_closed() { FetchCursor::parse("x".repeat(FETCH_CURSOR_MAX_BYTES + 1)).expect_err("oversized cursor"), Error::InvalidFetchCursor ); + for invalid in [" request", "request ", "request\nid"] { + assert_eq!( + FetchRequest::new( + invalid, + TargetSet::new(vec![target("wss://one.example")]).expect("targets"), + FetchBounds::new(1, 1).expect("bounds"), + ) + .expect_err("invalid request id"), + Error::InvalidFetchRequestId + ); + } + for invalid in [" cursor", "cursor ", "cursor\nid"] { + assert_eq!( + FetchCursor::parse(invalid).expect_err("invalid cursor"), + Error::InvalidFetchCursor + ); + } + + let bounds = FetchBounds::new(FETCH_PAGE_MAX_EVENTS, u64::MAX).expect("maximum bounds"); + assert_eq!(bounds.limit(), FETCH_PAGE_MAX_EVENTS); + assert_eq!(bounds.deadline_unix_ms(), u64::MAX); + let cursor = FetchCursor::parse("cursor").expect("cursor"); + assert_eq!(cursor.as_str(), "cursor"); + assert_eq!(cursor.to_string(), "cursor"); +} + +#[test] +fn selectors_expose_bounds_and_reject_each_nonmatching_dimension() { + let event = signed_event(); + let other_author = + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .expect("other author"); + + let reversed_since = FetchSelector::all() + .with_until_unix_seconds(1) + .expect("until") + .with_since_unix_seconds(2) + .expect_err("reversed since"); + assert_eq!(reversed_since, Error::InvalidFetchTimeRange); + + for selector in [ + FetchSelector::all().with_kinds(vec![1]).expect("kinds"), + FetchSelector::all() + .with_authors(vec![other_author]) + .expect("authors"), + FetchSelector::all() + .with_since_unix_seconds(event.created_at() + 1) + .expect("since"), + FetchSelector::all() + .with_until_unix_seconds(event.created_at() - 1) + .expect("until"), + ] { + assert!(!selector.matches(&event)); + } + + let selector = FetchSelector::all() + .with_since_unix_seconds(event.created_at()) + .expect("since") + .with_until_unix_seconds(event.created_at()) + .expect("until"); + assert_eq!(selector.since_unix_seconds(), Some(event.created_at())); + assert_eq!(selector.until_unix_seconds(), Some(event.created_at())); + assert!(selector.matches(&event)); } #[test] @@ -294,6 +358,34 @@ fn pages_reject_oversize_unrequested_and_duplicate_evidence() { .expect_err("request mismatch"), Error::FetchPageRequestMismatch ); + + let filtered = FetchRequest::new( + "filtered-request", + request.target_set().clone(), + request.bounds(), + ) + .expect("filtered request") + .with_selector( + FetchSelector::all() + .with_kinds(vec![1]) + .expect("filtered selector"), + ); + let unexpected = ObservedEvent::new( + signed_event(), + EventProvenance::new(TransportId::NOSTR, requested.fingerprint().clone(), 1) + .expect("provenance"), + ); + assert_eq!( + FetchPage::for_request(&filtered, vec![unexpected], Vec::new(), NextPage::Complete) + .expect_err("selector mismatch"), + Error::UnexpectedFetchEvent + ); + + assert_eq!( + EventProvenance::new(TransportId::NOSTR, requested.fingerprint().clone(), 0) + .expect_err("zero observation time"), + Error::InvalidObservedAt + ); } struct CancellationSource { diff --git a/crates/transport/tests/target_contract.rs b/crates/transport/tests/target_contract.rs @@ -8,6 +8,7 @@ use radroots_transport::{ }, target::TargetFingerprint, }; +use serde_json::Value; #[test] fn endpoint_scope_and_label_values_are_canonical_and_bounded() { @@ -71,3 +72,284 @@ fn fingerprints_and_target_sets_are_deterministic_unique_and_bounded() { Error::DuplicateTargetFingerprint ); } + +#[test] +fn transport_target_uri_vectors_match_the_canonical_parser() { + let document: Value = serde_json::from_str(include_str!("fixtures/target_uri.v1.json")) + .expect("transport target vectors"); + let vectors = document["vectors"].as_array().expect("vector array"); + + for vector in vectors { + let kind = vector["kind"].as_str().expect("vector kind"); + let raw = vector["input"]["uri"].as_str().expect("input URI"); + match kind { + "transport.target_uri.valid" => assert_eq!( + EndpointUri::parse(raw).expect("valid endpoint").as_str(), + vector["expected"]["canonical_uri"] + .as_str() + .expect("canonical URI") + ), + "transport.target_uri.invalid" => assert!(EndpointUri::parse(raw).is_err()), + "transport.nostr_relay_target.valid" => assert_eq!( + Target::nostr_relay(raw) + .expect("valid relay") + .uri() + .as_str(), + vector["expected"]["canonical_uri"] + .as_str() + .expect("canonical URI") + ), + "transport.nostr_relay_target.invalid" => { + assert!(Target::nostr_relay(raw).is_err()) + } + other => panic!("unknown vector kind {other}"), + } + } +} + +#[test] +fn generic_endpoint_parser_covers_opaque_authority_and_scheme_edges() { + let schemeless = EndpointUri::parse("transport-target").expect("schemeless URI"); + assert_eq!(schemeless.as_str(), "transport-target"); + assert_eq!(schemeless.to_string(), "transport-target"); + assert_eq!( + EndpointUri::parse("RNS:PeerA") + .expect("opaque URI") + .as_str(), + "rns:PeerA" + ); + assert_eq!( + EndpointUri::parse("MESH://Node.Example/path?q=1#frag") + .expect("authority URI") + .as_str(), + "mesh://node.example/path?q=1#frag" + ); + assert_eq!(EndpointUri::parse(" ").unwrap_err(), Error::EmptyTargetUri); + + for invalid in [ + "bad target", + " transport-target ", + ":target", + "1bad:target", + "bad_scheme://target", + "bad\ttarget", + "bad\ntarget", + ] { + assert_eq!( + EndpointUri::parse(invalid).unwrap_err(), + Error::InvalidTargetUri, + "{invalid:?} must fail" + ); + } +} + +#[test] +fn nostr_targets_canonicalize_identity_and_local_websocket_endpoints() { + let root = Target::nostr_relay("wss://relay.example").expect("root relay"); + let slash = Target::nostr_relay("WSS://RELAY.EXAMPLE/").expect("root slash relay"); + let default_port = Target::nostr_relay("wss://relay.example:443").expect("default port relay"); + let path = Target::nostr_relay("wss://relay.example/Events").expect("path relay"); + let generic = + Target::new(TransportId::NOSTR, "wss://relay.example/").expect("generic Nostr target"); + + assert_eq!(root.uri().as_str(), "wss://relay.example"); + assert_eq!(root.fingerprint(), slash.fingerprint()); + assert_eq!(root.fingerprint(), default_port.fingerprint()); + assert_eq!(root.fingerprint(), generic.fingerprint()); + assert_ne!(root.fingerprint(), path.fingerprint()); + assert_eq!(path.uri().as_str(), "wss://relay.example/Events"); + assert_eq!( + TargetSet::new(vec![root, slash]).unwrap_err(), + Error::DuplicateTargetFingerprint + ); + + for (raw, expected) in [ + ("ws://LOCALHOST:80/", "ws://localhost"), + ("ws://127.0.0.1:7777", "ws://127.0.0.1:7777"), + ("ws://[0:0:0:0:0:0:0:1]:7777", "ws://[::1]:7777"), + ("wss://[2001:0DB8::1]:443", "wss://[2001:db8::1]"), + ( + "wss://relay.example:7443/a%20b", + "wss://relay.example:7443/a%20b", + ), + ] { + assert_eq!( + Target::nostr_relay(raw) + .expect("valid relay") + .uri() + .as_str(), + expected + ); + } +} + +#[test] +fn nostr_targets_reject_ambiguous_authorities_hosts_ports_and_paths() { + let overlong = format!("wss://{}", "a".repeat(ENDPOINT_URI_MAX_BYTES)); + let overlong_dns_label = format!("wss://{}.example", "a".repeat(64)); + let overlong_host = format!("wss://{}.example", "a.".repeat(124)); + assert_eq!(Target::nostr_relay("").unwrap_err(), Error::EmptyTargetUri); + let invalid = [ + " wss://relay.example", + "wss://relay.example ", + "https://relay.example", + "wss:relay.example", + "wss://", + "wss://user@relay.example", + "wss://user:password@relay.example", + "wss://relay.example?subscription=1", + "wss://relay.example#fragment", + "wss://relay.example\\path", + "wss://relay.example:", + "wss://relay.example:0", + "wss://relay.example:01", + "wss://relay.example:65536", + "wss://relay.example:999999999999999999999999999999999999", + "wss://:443", + "wss://[", + "wss://[]", + "wss://[::1", + "wss://[::1]suffix", + "wss://[::1]:", + "wss://[not-ipv6]", + "wss://[::ffff:127.0.0.1x]", + "wss://relay[.]example", + "wss://127.0.0.01", + "wss://127.0.0.256", + "wss://127.0.0", + "wss://127.0.0.1.2", + "wss://-relay.example", + "wss://relay-.example", + "wss://relay..example", + "wss://xn--relay.example", + "wss://relay.123", + "wss://relay.0x10", + "wss://relay.ex_ample", + "ws://relay.example", + "ws://192.168.1.2", + "wss://relay.example/.", + "wss://relay.example/..", + "wss://relay.example/%2E", + "wss://relay.example/%2E%2E", + "wss://relay.example/%2e", + "wss://relay.example/%", + "wss://relay.example/%2", + "wss://relay.example/a[b", + ]; + for raw in invalid.into_iter().chain([ + overlong.as_str(), + overlong_dns_label.as_str(), + overlong_host.as_str(), + ]) { + assert_eq!( + Target::nostr_relay(raw).unwrap_err(), + Error::InvalidTargetUri, + "{raw:?} must fail" + ); + } +} + +#[test] +fn target_metadata_identity_and_collection_accessors_are_explicit() { + let scope = TargetScope::parse("local").expect("scope"); + let label = TargetLabel::parse(" Local node ").expect("label"); + let local = + Target::local_with_metadata("local:node-1", Some(scope.clone()), Some(label.clone())) + .expect("local target"); + let relabeled = Target::new_with_metadata( + TransportId::LOCAL, + "local:node-1", + Some(scope), + Some(TargetLabel::parse("Renamed node").expect("label")), + ) + .expect("relabeled target"); + let unscoped = Target::local("local:node-1").expect("unscoped target"); + + assert_eq!(local.kind(), &TransportId::LOCAL); + assert_eq!(local.uri().as_ref(), "local:node-1"); + assert_eq!(local.scope().map(TargetScope::as_str), Some("local")); + assert_eq!(local.label().map(TargetLabel::as_str), Some("Local node")); + assert_eq!(local.fingerprint(), relabeled.fingerprint()); + assert_ne!(local.fingerprint(), unscoped.fingerprint()); + + let set = TargetSet::new(vec![local.clone(), unscoped]).expect("target set"); + assert!(set.contains(local.fingerprint())); + assert!(!set.is_empty()); + assert_eq!(set.targets().len(), 2); + let foreign = Target::local("local:foreign").expect("foreign target"); + assert!(!set.contains(foreign.fingerprint())); + + assert_eq!(TargetScope::parse("").unwrap_err(), Error::EmptyTargetScope); + for value in [" scope", "scope ", "bad scope", "bad/scope", "bad\nscope"] { + assert_eq!( + TargetScope::parse(value).unwrap_err(), + Error::InvalidTargetScope + ); + } + assert_eq!( + TargetLabel::parse(" ").unwrap_err(), + Error::EmptyTargetLabel + ); + assert_eq!( + TargetLabel::parse("bad\nlabel").unwrap_err(), + Error::InvalidTargetLabel + ); + assert_eq!( + TargetFingerprint::parse("abc").unwrap_err(), + Error::InvalidTargetFingerprint + ); + assert_eq!( + TargetFingerprint::parse("g".repeat(64)).unwrap_err(), + Error::InvalidTargetFingerprint + ); + let uppercase = TargetFingerprint::parse(local.fingerprint().as_str().to_ascii_uppercase()) + .expect("uppercase fingerprint"); + assert_eq!(uppercase.as_str(), local.fingerprint().as_str()); + assert_eq!(uppercase.to_string(), local.fingerprint().as_str()); + assert_eq!( + TargetSet::new(Vec::new()).unwrap_err(), + Error::EmptyTargetSet + ); +} + +#[test] +#[cfg(feature = "serde")] +fn target_deserialization_revalidates_every_canonical_identity_field() { + let target = Target::nostr_relay_with_metadata( + "wss://relay.example/path", + Some(TargetScope::parse("public").expect("scope")), + Some(TargetLabel::parse("Relay").expect("label")), + ) + .expect("target"); + let value = serde_json::to_value(&target).expect("target JSON"); + assert_eq!( + serde_json::from_value::<Target>(value.clone()).expect("target round trip"), + target + ); + + for (field, forged) in [ + ("uri", Value::String("WSS://RELAY.EXAMPLE/path".into())), + ("scope", Value::String("bad scope".into())), + ("label", Value::String(" Relay ".into())), + ("fingerprint", Value::String("A".repeat(64))), + ("fingerprint", Value::String("0".repeat(64))), + ] { + let mut invalid = value.clone(); + invalid[field] = forged; + assert!( + serde_json::from_value::<Target>(invalid).is_err(), + "{field}" + ); + } + + let set = TargetSet::new(vec![target]).expect("target set"); + let encoded = serde_json::to_value(&set).expect("target set JSON"); + assert_eq!( + serde_json::from_value::<TargetSet>(encoded.clone()).expect("set round trip"), + set + ); + let mut duplicate = encoded; + let first = duplicate["targets"][0].clone(); + duplicate["targets"].as_array_mut().unwrap().push(first); + assert!(serde_json::from_value::<TargetSet>(duplicate).is_err()); +} diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,1864 +0,0 @@ -use radroots_transport::target::{EndpointUri, TargetFingerprint, TargetLabel, TargetScope}; -use radroots_transport::{ - RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RadrootsTransportCapabilities, - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, - RadrootsTransportImplementationState, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportPayload, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTargetReceipt, - TRANSPORT_ID_MAX_BYTES, Target, TargetSet, TransportId, -}; -use serde_json::Value; -use std::borrow::ToOwned; -use std::string::ToString; -use std::vec; -use std::vec::Vec; - -fn opaque_payload() -> RadrootsTransportPayload { - RadrootsTransportPayload::opaque_bytes("transport-test-payload", b"transport payload") - .expect("payload") -} - -fn reticulum_target() -> Target { - Target::new_with_metadata( - TransportId::RETICULUM, - "reticulum:local", - Some(TargetScope::parse("local").expect("Reticulum scope")), - None, - ) - .expect("Reticulum target") -} - -#[test] -fn target_fingerprints_are_stable_and_transport_scoped() { - let nostr_upper = Target::nostr_relay("WSS://Relay.Example/Events").expect("nostr target"); - let nostr_lower = Target::nostr_relay("wss://relay.example/Events").expect("nostr target"); - let reticulum = reticulum_target(); - - assert_eq!(nostr_upper.uri().as_str(), "wss://relay.example/Events"); - assert_eq!(nostr_upper.scope(), None); - assert_eq!(reticulum.scope().map(|scope| scope.as_str()), Some("local")); - assert_eq!(nostr_upper.fingerprint(), nostr_lower.fingerprint()); - assert_ne!(nostr_upper.fingerprint(), reticulum.fingerprint()); - assert_eq!( - nostr_upper.fingerprint().as_str(), - "d0903c3067150d7b4f7efd92a9be002b97d74e83f8bb6827327fa7ecd869332b" - ); -} - -#[test] -fn transport_id_round_trips_built_ins_and_custom_values() { - for (raw, expected) in [ - ("local", TransportId::LOCAL), - ("nostr", TransportId::NOSTR), - ("reticulum", TransportId::RETICULUM), - ("radrootsd", TransportId::RADROOTSD), - ] { - let parsed = TransportId::parse(raw).expect("built-in transport id"); - assert_eq!(parsed, expected); - assert_eq!(parsed.as_str(), raw); - assert_eq!(parsed.to_string(), raw); - } - - let custom = TransportId::parse("fieldbus-v2").expect("custom transport id"); - assert_eq!(custom.as_str(), "fieldbus-v2"); - let custom_target = Target::new(custom, "fieldbus:node-7").expect("custom target"); - assert_eq!(custom_target.kind(), &custom); - assert_eq!(TransportId::LOCAL.canonical_label(), "local".to_owned()); -} - -#[test] -fn transport_id_parser_enforces_canonical_syntax_and_bound() { - assert_eq!( - TransportId::parse_canonical("nostr").expect("nostr kind"), - TransportId::NOSTR - ); - assert_eq!( - TransportId::parse_canonical("NOSTR").expect_err("uppercase kind"), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - TransportId::parse_canonical(" nostr ").expect_err("trimmed kind"), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - TransportId::parse_canonical("radrootsd_proxy").expect_err("underscore separator"), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - TransportId::parse_canonical("fieldbus").expect("custom kind"), - TransportId::parse("fieldbus").expect("same custom kind") - ); - assert_eq!( - TransportId::parse_canonical("").expect_err("empty kind"), - RadrootsTransportError::EmptyTransportKind - ); - assert_eq!( - TransportId::parse("a".repeat(TRANSPORT_ID_MAX_BYTES + 1)).expect_err("overlong kind"), - RadrootsTransportError::InvalidTransportKind - ); -} - -#[test] -fn target_set_rejects_duplicate_fingerprints() { - let first = Target::nostr_relay("wss://relay.example/a").expect("first target"); - let duplicate = Target::nostr_relay("WSS://RELAY.EXAMPLE/a").expect("duplicate target"); - let err = TargetSet::new(vec![first, duplicate]).expect_err("duplicate fingerprints must fail"); - - assert_eq!(err, RadrootsTransportError::DuplicateTargetFingerprint); -} - -#[test] -fn nostr_relay_targets_use_canonical_endpoint_identity() { - let root = Target::nostr_relay("wss://relay.example").expect("root target"); - let root_slash = Target::nostr_relay("WSS://RELAY.EXAMPLE/").expect("root slash target"); - let root_default_port = - Target::nostr_relay("wss://relay.example:443").expect("default-port root target"); - let path = Target::nostr_relay("wss://relay.example/path").expect("path target"); - let generic = - Target::new(TransportId::NOSTR, "wss://relay.example/").expect("generic nostr target"); - - assert_eq!(root.uri().as_str(), "wss://relay.example"); - assert_eq!(root_slash.uri().as_str(), "wss://relay.example"); - assert_eq!(root_default_port.uri().as_str(), "wss://relay.example"); - assert_eq!(root.fingerprint(), root_slash.fingerprint()); - assert_eq!(root.fingerprint(), root_default_port.fingerprint()); - assert_eq!(root.fingerprint(), generic.fingerprint()); - assert_ne!(root.fingerprint(), path.fingerprint()); - assert_eq!(path.uri().as_str(), "wss://relay.example/path"); - assert_eq!( - TargetSet::new(vec![root, root_slash]).expect_err("canonical-equivalent roots collide"), - RadrootsTransportError::DuplicateTargetFingerprint - ); -} - -#[test] -fn nostr_relay_targets_reject_noncanonical_or_unsupported_endpoint_forms() { - for invalid in [ - "https://relay.example", - "wss://user@relay.example", - "wss://user:password@relay.example", - "wss://relay.example?subscription=1", - "wss://relay.example#fragment", - "wss://", - "wss://relay.example:bad", - "wss://relay.example:0", - "wss://relay.example:01", - "wss://relay.example:65536", - "wss://:443", - "wss://[not-ipv6]", - "wss://relay.example\\path", - "wss://relay.example/%2f", - "ws://relay.example", - ] { - assert_eq!( - Target::nostr_relay(invalid).expect_err("invalid Nostr relay target"), - RadrootsTransportError::InvalidTargetUri - ); - } - - let local_ws = Target::nostr_relay("ws://LOCALHOST:7777/").expect("local ws relay"); - assert_eq!(local_ws.uri().as_str(), "ws://localhost:7777"); - let local_ipv6 = Target::nostr_relay("ws://[::1]:7777").expect("local ipv6 relay"); - assert_eq!(local_ipv6.uri().as_str(), "ws://[::1]:7777"); -} - -#[test] -fn satisfaction_policy_counts_target_statuses() { - let no_wait = RadrootsTransportSatisfactionPolicy::no_wait(); - let all = RadrootsTransportSatisfactionPolicy::all_accepted(); - let any = RadrootsTransportSatisfactionPolicy::any_accepted(); - let two = RadrootsTransportSatisfactionPolicy::quorum_accepted(2); - let delivered = RadrootsTransportSatisfactionPolicy::quorum_delivered(2); - let forwarded = RadrootsTransportSatisfactionPolicy::any_forwarded(); - let stored = RadrootsTransportSatisfactionPolicy::all_stored(); - let seen = RadrootsTransportSatisfactionPolicy::quorum_seen(2); - let durable_or_observed = RadrootsTransportSatisfactionPolicy::any_durable_or_observed(); - - assert_eq!(no_wait.required_target_count(0).expect("no wait"), 0); - assert_eq!(no_wait.required_target_count(3).expect("no wait"), 0); - assert!(no_wait.is_satisfied_by(0, 0).expect("no wait")); - assert_ne!(no_wait, all); - assert!(all.is_satisfied_by(2, 2).expect("all")); - assert!(!all.is_satisfied_by(2, 1).expect("all incomplete")); - assert!(any.is_satisfied_by(3, 1).expect("any")); - assert!(two.is_satisfied_by(3, 2).expect("two")); - assert_eq!(no_wait.target_satisfaction_class(), None); - assert_eq!( - all.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Accepted) - ); - assert_eq!( - delivered.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Delivered) - ); - assert_eq!( - forwarded.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Forwarded) - ); - assert_eq!( - stored.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Stored) - ); - assert_eq!( - seen.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Seen) - ); - assert_eq!( - durable_or_observed.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::DurableOrObserved) - ); - for (policy, class) in [ - ( - RadrootsTransportSatisfactionPolicy::all_forwarded(), - RadrootsTransportSatisfactionClass::Forwarded, - ), - ( - RadrootsTransportSatisfactionPolicy::quorum_forwarded(2), - RadrootsTransportSatisfactionClass::Forwarded, - ), - ( - RadrootsTransportSatisfactionPolicy::any_stored(), - RadrootsTransportSatisfactionClass::Stored, - ), - ( - RadrootsTransportSatisfactionPolicy::quorum_stored(2), - RadrootsTransportSatisfactionClass::Stored, - ), - ( - RadrootsTransportSatisfactionPolicy::any_seen(), - RadrootsTransportSatisfactionClass::Seen, - ), - ( - RadrootsTransportSatisfactionPolicy::all_seen(), - RadrootsTransportSatisfactionClass::Seen, - ), - ( - RadrootsTransportSatisfactionPolicy::all_durable_or_observed(), - RadrootsTransportSatisfactionClass::DurableOrObserved, - ), - ( - RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2), - RadrootsTransportSatisfactionClass::DurableOrObserved, - ), - ] { - assert_eq!(policy.target_satisfaction_class(), Some(class)); - } - assert_eq!( - any.is_satisfied_by(0, 0).expect_err("zero target set"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::quorum_accepted(0) - .is_satisfied_by(3, 0) - .expect_err("zero required targets"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); -} - -#[test] -#[cfg(feature = "serde")] -fn transport_status_models_canonical_configuration_and_delivery_usability() { - let status = RadrootsTransportStatus::new( - TransportId::NOSTR, - true, - RadrootsTransportImplementationState::Real, - true, - "ready", - ) - .with_profile_id("transport.nostr.default") - .with_endpoint_uri("wss://relay.example"); - - assert_eq!(status.kind, TransportId::NOSTR); - assert_eq!( - status.profile_id.as_deref(), - Some("transport.nostr.default") - ); - assert_eq!(status.endpoint_uri.as_deref(), Some("wss://relay.example")); - assert!(status.configured); - assert_eq!( - status.implementation, - RadrootsTransportImplementationState::Real - ); - assert!(status.usable_for_delivery); - assert_eq!( - status.capabilities, - RadrootsTransportCapabilities::deliver_only() - ); - assert_eq!(status.message, "ready"); - - let json = serde_json::to_value(&status).expect("status json"); - assert_eq!(json["transport"], "nostr"); - assert_eq!(json["implementation"], "real"); - assert_eq!(json["configured"], true); - assert_eq!(json["usable_for_delivery"], true); - assert_eq!(json["capabilities"]["deliver"], true); - assert_eq!(json["capabilities"]["fetch"], false); - assert_eq!(json["capabilities"]["discovery"], false); - assert_eq!(json["capabilities"]["gateway_forwarding"], false); - assert_eq!(json["capabilities"]["receipt_observation"], false); - assert_eq!(json["message"], "ready"); - for retired in [ - "kind", - "implementation_state", - "readiness", - "publish_usable", - "fetch_usable", - "redacted_message", - ] { - assert!( - json.get(retired).is_none(), - "retired status field {retired}" - ); - } -} - -#[test] -fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { - let target = reticulum_target(); - let receipt = RadrootsTransportDeliveryReceipt::new( - "reticulum", - TargetSet::new(vec![target.clone()]).expect("target set"), - vec![RadrootsTransportTargetReceipt::new( - target, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented), - )], - ) - .expect("receipt"); - - assert!( - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - .is_deferred_until_implemented() - ); - assert_eq!( - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), - 0 - ); - assert!( - !RadrootsTransportSatisfactionPolicy::any_accepted() - .is_satisfied_by( - 1, - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted) - ) - .expect("satisfaction check") - ); -} - -#[test] -#[cfg(feature = "serde")] -fn request_models_round_trip_with_serde() { - let target = Target::nostr_relay("wss://relay.example").expect("target"); - let target_set = TargetSet::new(vec![target]).expect("target set"); - let request = RadrootsTransportDeliveryRequest::new( - "req-1", - opaque_payload(), - target_set, - RadrootsTransportSatisfactionPolicy::any_accepted(), - ) - .expect("request"); - - let json = serde_json::to_string(&request).expect("serialize request"); - let decoded: RadrootsTransportDeliveryRequest = - serde_json::from_str(&json).expect("decode request"); - - assert_eq!(decoded, request); -} - -#[test] -fn payload_contract_derives_and_validates_unchecked_signed_event_digests() { - let event_id = "a".repeat(64); - let signed = RadrootsTransportPayload::unchecked_signed_event_json( - event_id.as_str(), - "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", - ) - .expect("unchecked signed event payload"); - assert_eq!(signed.payload_kind(), "signed_event_json"); - assert_eq!(signed.digest().len(), 64); - assert!( - signed - .digest() - .bytes() - .all(|byte| { byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') }) - ); - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json_with_digest( - event_id.as_str(), - "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", - signed.digest(), - ) - .expect("unchecked signed payload with digest"), - signed - ); - - let mesh = - RadrootsTransportPayload::mesh_frame_cbor("mesh.message-1", [1_u8, 2, 3]).expect("mesh"); - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor_with_digest( - "mesh.message-1", - [1_u8, 2, 3], - mesh.digest(), - ) - .expect("mesh with digest"), - mesh - ); - - let opaque = RadrootsTransportPayload::opaque_bytes("operator note", b"bytes").expect("opaque"); - assert_eq!( - RadrootsTransportPayload::opaque_bytes_with_digest( - "operator note", - b"bytes", - opaque.digest(), - ) - .expect("opaque with digest"), - opaque - ); -} - -#[test] -fn payload_contract_rejects_invalid_unchecked_signed_event_ids_bytes_labels_and_digests() { - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json("A".repeat(64), "{}") - .expect_err("uppercase event id"), - RadrootsTransportError::InvalidPayloadId - ); - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), " [] ") - .expect_err("non-object json"), - RadrootsTransportError::InvalidPayloadBytes - ); - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor("mesh message", [1_u8]) - .expect_err("space in message id"), - RadrootsTransportError::InvalidPayloadId - ); - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor("mesh-message", []) - .expect_err("empty mesh bytes"), - RadrootsTransportError::EmptyPayloadBytes - ); - assert_eq!( - RadrootsTransportPayload::opaque_bytes("bad\u{0007}", b"bytes") - .expect_err("control character label"), - RadrootsTransportError::InvalidPayloadLabel - ); - assert_eq!( - RadrootsTransportPayload::opaque_bytes_with_digest("label", b"bytes", "f".repeat(64)) - .expect_err("digest mismatch"), - RadrootsTransportError::PayloadDigestMismatch - ); - assert_eq!( - RadrootsTransportPayload::opaque_bytes_with_digest("label", b"bytes", "F".repeat(64)) - .expect_err("uppercase digest"), - RadrootsTransportError::InvalidPayloadDigest - ); -} - -#[test] -fn fingerprint_parser_rejects_non_sha256_hex() { - assert_eq!( - TargetFingerprint::parse("abc").expect_err("short fingerprint"), - RadrootsTransportError::InvalidTargetFingerprint - ); - assert_eq!( - EndpointUri::parse("wss://relay example").expect_err("space in target uri"), - RadrootsTransportError::InvalidTargetUri - ); -} - -#[test] -fn transport_errors_have_stable_display_strings() { - let cases = [ - ( - RadrootsTransportError::UnsupportedOperation, - "transport operation is unsupported", - ), - ( - RadrootsTransportError::EmptyTransportKind, - "transport kind is empty", - ), - ( - RadrootsTransportError::InvalidTransportKind, - "transport kind is invalid", - ), - ( - RadrootsTransportError::EmptyTargetUri, - "transport target URI is empty", - ), - ( - RadrootsTransportError::InvalidTargetUri, - "transport target URI is invalid", - ), - ( - RadrootsTransportError::EmptyTargetScope, - "transport target scope is empty", - ), - ( - RadrootsTransportError::InvalidTargetScope, - "transport target scope is invalid", - ), - ( - RadrootsTransportError::EmptyTargetLabel, - "transport target label is empty", - ), - ( - RadrootsTransportError::InvalidTargetLabel, - "transport target label is invalid", - ), - ( - RadrootsTransportError::EmptyTargetSet, - "transport target set is empty", - ), - ( - RadrootsTransportError::TargetSetTooLarge, - "transport target set exceeds its item limit", - ), - ( - RadrootsTransportError::DuplicateTargetFingerprint, - "transport target set contains duplicate fingerprints", - ), - ( - RadrootsTransportError::InvalidTargetFingerprint, - "transport target fingerprint is invalid", - ), - ( - RadrootsTransportError::InvalidSatisfactionPolicy, - "transport satisfaction policy is invalid", - ), - ( - RadrootsTransportError::EmptyRequiredTargetSet, - "transport required target set is empty", - ), - ( - RadrootsTransportError::DuplicateRequiredTargetFingerprint, - "transport required target set contains duplicate fingerprints", - ), - ]; - - for (error, message) in cases { - assert_eq!(error.to_string(), message); - } -} - -#[test] -fn transport_kind_and_target_parsers_cover_negative_edges() { - for invalid in [ - "bad kind", - "bad:kind", - "bad/kind", - "bad\nkind", - "-fieldbus", - "fieldbus-", - "fieldbus--v2", - ] { - assert_eq!( - TransportId::parse(invalid).expect_err("invalid kind"), - RadrootsTransportError::InvalidTransportKind - ); - } - - let no_scheme = EndpointUri::parse("transport-target").expect("schemeless target uri"); - assert_eq!(no_scheme.as_str(), "transport-target"); - assert_eq!(no_scheme.to_string(), "transport-target"); - let opaque = EndpointUri::parse("RNS:PeerA").expect("opaque uri"); - assert_eq!(opaque.as_str(), "rns:PeerA"); - let authority = EndpointUri::parse("MESH://Node.Example/path?q=1#frag").expect("authority uri"); - assert_eq!(authority.as_str(), "mesh://node.example/path?q=1#frag"); - - assert_eq!( - EndpointUri::parse(" ").expect_err("empty uri"), - RadrootsTransportError::EmptyTargetUri - ); - for invalid in [ - "bad target", - " transport-target ", - ":target", - "1bad:target", - "bad_scheme://target", - "bad\target", - ] { - assert_eq!( - EndpointUri::parse(invalid).expect_err("invalid uri"), - RadrootsTransportError::InvalidTargetUri - ); - } -} - -#[test] -fn checked_in_transport_target_uri_vectors_match_parser_behavior() { - let vectors = include_str!("fixtures/target_uri.v1.json"); - let document: Value = serde_json::from_str(vectors).expect("transport target vector json"); - let entries = document - .get("vectors") - .and_then(Value::as_array) - .expect("transport target vectors"); - - for entry in entries { - let kind = entry.get("kind").and_then(Value::as_str).expect("kind"); - let raw_uri = entry - .get("input") - .and_then(|input| input.get("uri")) - .and_then(Value::as_str) - .expect("input uri"); - let expected = entry.get("expected").expect("expected"); - match kind { - "transport.target_uri.valid" => { - let target = EndpointUri::parse(raw_uri).expect("target URI"); - assert_eq!( - target.as_str(), - expected - .get("canonical_uri") - .and_then(Value::as_str) - .expect("canonical uri") - ); - } - "transport.target_uri.invalid" => { - assert!(EndpointUri::parse(raw_uri).is_err()); - } - "transport.nostr_relay_target.valid" => { - let target = Target::nostr_relay(raw_uri).expect("relay target"); - assert_eq!( - target.uri().as_str(), - expected - .get("canonical_uri") - .and_then(Value::as_str) - .expect("canonical uri") - ); - } - "transport.nostr_relay_target.invalid" => { - assert!(Target::nostr_relay(raw_uri).is_err()); - } - other => panic!("unknown transport target vector kind {other}"), - } - } -} - -#[test] -fn generic_targets_do_not_own_reticulum_destination_policy() { - let target = reticulum_target(); - assert_eq!(target.uri().as_str(), "reticulum:local"); - assert_eq!(target.scope().map(|scope| scope.as_str()), Some("local")); - - let remote = Target::new(TransportId::RETICULUM, "reticulum:remote") - .expect("adapter-owned Reticulum destination is accepted generically"); - assert_eq!(remote.uri().as_str(), "reticulum:remote"); - assert_eq!(remote.scope(), None); - - for invalid in [" reticulum:local", "reticulum:local "] { - assert_eq!( - Target::new(TransportId::RETICULUM, invalid).expect_err("invalid generic endpoint"), - RadrootsTransportError::InvalidTargetUri - ); - } -} - -#[test] -fn target_fingerprints_and_sets_cover_accessors_and_validation() { - let target = reticulum_target(); - let parsed = TargetFingerprint::parse(target.fingerprint().as_str().to_ascii_uppercase()) - .expect("uppercase fingerprint parses"); - assert_eq!(parsed.as_str(), target.fingerprint().as_str()); - assert_eq!(parsed.to_string(), target.fingerprint().as_str()); - assert_eq!( - TargetFingerprint::parse("g".repeat(64)).expect_err("non-hex fingerprint"), - RadrootsTransportError::InvalidTargetFingerprint - ); - - assert_eq!( - TargetSet::new(Vec::new()).expect_err("empty target set"), - RadrootsTransportError::EmptyTargetSet - ); - let target_set = TargetSet::new(vec![target]).expect("target set"); - assert_eq!(target_set.len(), 1); - assert!(!target_set.is_empty()); - assert_eq!(target_set.targets().len(), 1); -} - -#[test] -fn target_scope_participates_in_identity_and_label_does_not() { - let local_scope = TargetScope::parse("local").expect("local scope"); - let remote_scope = TargetScope::parse("remote").expect("remote scope"); - let local = Target::new_with_metadata( - TransportId::RETICULUM, - "reticulum:local", - Some(local_scope.clone()), - Some(TargetLabel::parse("Local Reticulum node").expect("label")), - ) - .expect("local Reticulum target"); - let relabeled = Target::new_with_metadata( - TransportId::RETICULUM, - "reticulum:local", - Some(local_scope), - Some(TargetLabel::parse("Renamed node").expect("label")), - ) - .expect("relabeled mesh target"); - let remote = Target::new_with_metadata( - TransportId::RETICULUM, - "reticulum:local", - Some(remote_scope), - None, - ) - .expect("remote Reticulum target"); - - assert_eq!(local.fingerprint(), relabeled.fingerprint()); - assert_ne!(local.fingerprint(), remote.fingerprint()); - assert_eq!(local.scope().map(|scope| scope.as_str()), Some("local")); - assert_eq!( - local.label().map(|label| label.as_str()), - Some("Local Reticulum node") - ); - assert_eq!( - TargetScope::parse("").expect_err("empty scope"), - RadrootsTransportError::EmptyTargetScope - ); - assert_eq!( - TargetScope::parse("bad scope").expect_err("invalid scope"), - RadrootsTransportError::InvalidTargetScope - ); - assert_eq!( - TargetLabel::parse(" ").expect_err("empty label"), - RadrootsTransportError::EmptyTargetLabel - ); - assert_eq!( - TargetLabel::parse("bad\nlabel").expect_err("invalid label"), - RadrootsTransportError::InvalidTargetLabel - ); -} - -#[test] -fn satisfaction_and_target_status_cover_all_contract_states() { - assert_eq!( - RadrootsTransportSatisfactionPolicy::no_wait() - .required_target_count(0) - .expect("no wait"), - 0 - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::all_accepted() - .required_target_count(3) - .expect("all targets"), - 3 - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::any_accepted() - .required_target_count(3) - .expect("any target"), - 1 - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::quorum_accepted(4) - .required_target_count(3) - .expect_err("at least too high"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); - - let classes = [ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - RadrootsTransportSatisfactionClass::Stored, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::Delivered, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ]; - let cases: &[( - RadrootsTransportDeliveryTargetStatus, - &[RadrootsTransportSatisfactionClass], - )] = &[ - (RadrootsTransportDeliveryTargetStatus::Pending, &[]), - ( - RadrootsTransportDeliveryTargetStatus::Accepted, - &[RadrootsTransportSatisfactionClass::Accepted], - ), - ( - RadrootsTransportDeliveryTargetStatus::Delivered, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::Delivered, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportDeliveryTargetStatus::Forwarded, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - ], - ), - ( - RadrootsTransportDeliveryTargetStatus::StoredByGateway, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Stored, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportDeliveryTargetStatus::Seen, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, - &[], - ), - ( - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, - &[], - ), - ( - RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied, - &[], - ), - (RadrootsTransportDeliveryTargetStatus::FailedRetryable, &[]), - (RadrootsTransportDeliveryTargetStatus::FailedTerminal, &[]), - ]; - assert!(RadrootsTransportDeliveryTargetStatus::Pending.is_ready_for_attempt()); - assert!(RadrootsTransportDeliveryTargetStatus::FailedRetryable.is_ready_for_attempt()); - for (status, satisfied_classes) in cases { - for class in classes { - assert_eq!( - status.counts_as_satisfied(class), - satisfied_classes.contains(&class), - "{status:?} / {class:?}" - ); - } - } - assert!( - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - .is_deferred_until_implemented() - ); - assert!(RadrootsTransportDeliveryTargetStatus::FailedRetryable.is_retryable_failure()); - assert!(RadrootsTransportDeliveryTargetStatus::FailedTerminal.is_terminal_failure()); -} - -#[test] -fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { - let classes = [ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - RadrootsTransportSatisfactionClass::Stored, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::Delivered, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ]; - let cases = [ - ( - RadrootsTransportOutcomeKind::Accepted, - "accepted", - RadrootsTransportDeliveryTargetStatus::Accepted, - &[RadrootsTransportSatisfactionClass::Accepted] as &[_], - ), - ( - RadrootsTransportOutcomeKind::DuplicateAccepted, - "duplicate_accepted", - RadrootsTransportDeliveryTargetStatus::Accepted, - &[RadrootsTransportSatisfactionClass::Accepted], - ), - ( - RadrootsTransportOutcomeKind::Delivered, - "delivered", - RadrootsTransportDeliveryTargetStatus::Delivered, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::Delivered, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportOutcomeKind::Forwarded, - "forwarded", - RadrootsTransportDeliveryTargetStatus::Forwarded, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Forwarded, - ], - ), - ( - RadrootsTransportOutcomeKind::StoredByGateway, - "stored_by_gateway", - RadrootsTransportDeliveryTargetStatus::StoredByGateway, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Stored, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportOutcomeKind::Seen, - "seen", - RadrootsTransportDeliveryTargetStatus::Seen, - &[ - RadrootsTransportSatisfactionClass::Accepted, - RadrootsTransportSatisfactionClass::Seen, - RadrootsTransportSatisfactionClass::DurableOrObserved, - ], - ), - ( - RadrootsTransportOutcomeKind::DeferredUntilImplemented, - "deferred_until_implemented", - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, - &[], - ), - ( - RadrootsTransportOutcomeKind::Rejected, - "rejected", - RadrootsTransportDeliveryTargetStatus::FailedTerminal, - &[], - ), - ( - RadrootsTransportOutcomeKind::RouteUnavailable, - "route_unavailable", - RadrootsTransportDeliveryTargetStatus::FailedTerminal, - &[], - ), - ( - RadrootsTransportOutcomeKind::PayloadTooLarge, - "payload_too_large", - RadrootsTransportDeliveryTargetStatus::FailedTerminal, - &[], - ), - ( - RadrootsTransportOutcomeKind::PolicyDenied, - "policy_denied", - RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied, - &[], - ), - ( - RadrootsTransportOutcomeKind::Timeout, - "timeout", - RadrootsTransportDeliveryTargetStatus::FailedRetryable, - &[], - ), - ( - RadrootsTransportOutcomeKind::ConnectionFailed, - "connection_failed", - RadrootsTransportDeliveryTargetStatus::FailedRetryable, - &[], - ), - ( - RadrootsTransportOutcomeKind::TransportUnavailable, - "transport_unavailable", - RadrootsTransportDeliveryTargetStatus::FailedRetryable, - &[], - ), - ]; - - for (kind, label, status, satisfied_classes) in cases { - let outcome = RadrootsTransportOutcome::new(kind).with_message("transport detail"); - assert_eq!(kind.as_str(), label); - assert_eq!(outcome.kind, kind); - assert_eq!(outcome.status, status); - for class in classes { - assert_eq!( - kind.counts_as_satisfied(class), - satisfied_classes.contains(&class), - "{kind:?} / {class:?}" - ); - } - assert_eq!(outcome.message.as_deref(), Some("transport detail")); - } - - let deferred_until_implemented = - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented); - assert_eq!( - deferred_until_implemented.status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - deferred_until_implemented - .validate() - .expect("canonical outcome"); -} - -#[test] -fn required_target_satisfaction_uses_fingerprints_not_target_counts() { - let required = Target::nostr_relay("wss://one.example").expect("required target"); - let optional = Target::nostr_relay("wss://two.example").expect("optional target"); - let policy = RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - vec![required.fingerprint().clone()], - ) - .expect("required target policy"); - assert_eq!(policy.required_target_count(2).expect("required count"), 1); - assert_eq!( - policy - .is_satisfied_by(2, 1) - .expect_err("count-only required targets are invalid"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); - assert_eq!( - policy - .required_target_count(0) - .expect_err("required target count exceeds total targets"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); - assert_eq!( - policy.target_satisfaction_class(), - Some(RadrootsTransportSatisfactionClass::Accepted) - ); - assert_eq!( - policy - .required_target_fingerprints() - .expect("required targets"), - std::slice::from_ref(required.fingerprint()) - ); - let unordered_policy = RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - vec![ - optional.fingerprint().clone(), - required.fingerprint().clone(), - ], - ) - .expect("unordered required targets"); - let mut expected_required_targets = vec![ - required.fingerprint().clone(), - optional.fingerprint().clone(), - ]; - expected_required_targets.sort(); - assert_eq!( - unordered_policy - .required_target_fingerprints() - .expect("canonical required targets"), - expected_required_targets.as_slice() - ); - - let optional_only = RadrootsTransportDeliveryReceipt::new( - "required-target", - TargetSet::new(vec![optional.clone()]).expect("optional target set"), - vec![RadrootsTransportTargetReceipt::new( - optional.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - )], - ) - .expect("optional receipt"); - assert_eq!( - optional_only - .is_satisfied_by(&policy) - .expect_err("unrequested required target"), - RadrootsTransportError::RequiredTargetNotRequested - ); - - let required_delivered = RadrootsTransportDeliveryReceipt::new( - "required-target", - TargetSet::new(vec![optional.clone(), required.clone()]).expect("required target set"), - vec![ - RadrootsTransportTargetReceipt::new( - optional, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected), - ), - RadrootsTransportTargetReceipt::new( - required.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ), - ], - ) - .expect("required receipt"); - assert!( - required_delivered - .is_satisfied_by(&policy) - .expect("required target accepted") - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - Vec::new(), - ) - .expect_err("empty required targets"), - RadrootsTransportError::EmptyRequiredTargetSet - ); - assert_eq!( - RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - vec![ - required.fingerprint().clone(), - required.fingerprint().clone(), - ], - ) - .expect_err("duplicate required target"), - RadrootsTransportError::DuplicateRequiredTargetFingerprint - ); -} - -#[test] -fn delivery_contract_covers_every_policy_and_receipt_path() { - let one = Target::nostr_relay("wss://one.example").expect("one"); - let two = Target::nostr_relay("wss://two.example").expect("two"); - let request = RadrootsTransportDeliveryRequest::new( - "delivery", - opaque_payload(), - TargetSet::new(vec![one.clone(), two.clone()]).expect("target set"), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect("delivery request") - .try_with_now_ms(42) - .expect("delivery timestamp"); - assert_eq!(request.now_ms(), 42); - - for (policy, class) in [ - ( - RadrootsTransportSatisfactionPolicy::any_delivered(), - RadrootsTransportSatisfactionClass::Delivered, - ), - ( - RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2), - RadrootsTransportSatisfactionClass::DurableOrObserved, - ), - ] { - assert_eq!(policy.target_satisfaction_class(), Some(class)); - } - for policy in [ - RadrootsTransportSatisfactionPolicy::no_wait(), - RadrootsTransportSatisfactionPolicy::any_accepted(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - RadrootsTransportSatisfactionPolicy::quorum_accepted(1), - ] { - assert!(policy.required_target_fingerprints().is_none()); - } - - let required = RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - vec![one.fingerprint().clone(), two.fingerprint().clone()], - ) - .expect("required policy"); - assert_eq!( - required - .required_target_count(1) - .expect_err("required set exceeds total"), - RadrootsTransportError::InvalidSatisfactionPolicy - ); - - let accepted = RadrootsTransportTargetReceipt::new( - one.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ); - let rejected = RadrootsTransportTargetReceipt::new( - two.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected), - ); - let receipt = RadrootsTransportDeliveryReceipt::new( - "delivery", - TargetSet::new(vec![one.clone(), two]).expect("receipt target set"), - vec![accepted, rejected], - ) - .expect("delivery receipt"); - assert!( - receipt - .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::no_wait()) - .expect("no wait") - ); - assert!( - receipt - .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::any_accepted()) - .expect("any") - ); - assert!( - !receipt - .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::all_accepted()) - .expect("all") - ); - assert!( - receipt - .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::quorum_accepted(1)) - .expect("quorum") - ); - assert!(!receipt.is_satisfied_by(&required).expect("required")); - - let invalid_empty = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: Vec::new(), - }; - assert_eq!( - invalid_empty - .required_target_count(2) - .expect_err("empty required set"), - RadrootsTransportError::EmptyRequiredTargetSet - ); - assert_eq!( - receipt - .is_satisfied_by(&invalid_empty) - .expect_err("empty required receipt policy"), - RadrootsTransportError::EmptyRequiredTargetSet - ); - - let duplicate = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: vec![one.fingerprint().clone(), one.fingerprint().clone()], - }; - assert_eq!( - duplicate - .required_target_count(2) - .expect_err("duplicate required set"), - RadrootsTransportError::DuplicateRequiredTargetFingerprint - ); -} - -#[test] -fn delivery_requests_and_receipts_reject_forged_identity_and_cardinality() { - let first = Target::nostr_relay("wss://one.example").expect("first"); - let second = Target::nostr_relay("wss://two.example").expect("second"); - let unexpected = Target::nostr_relay("wss://unexpected.example").expect("unexpected"); - let targets = TargetSet::new(vec![first.clone(), second.clone()]).expect("target set"); - let payload = opaque_payload(); - - assert_eq!( - RadrootsTransportDeliveryRequest::new( - "", - payload.clone(), - targets.clone(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect_err("empty request id"), - RadrootsTransportError::EmptyDeliveryRequestId - ); - for request_id in [ - " request".to_owned(), - "request\n".to_owned(), - "x".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES + 1), - ] { - assert_eq!( - RadrootsTransportDeliveryRequest::new( - request_id, - payload.clone(), - targets.clone(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect_err("invalid request id"), - RadrootsTransportError::InvalidDeliveryRequestId - ); - } - - let unrequested_policy = RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Accepted, - vec![unexpected.fingerprint().clone()], - ) - .expect("required policy"); - assert_eq!( - RadrootsTransportDeliveryRequest::new( - "request", - payload.clone(), - targets.clone(), - unrequested_policy, - ) - .expect_err("unrequested required target"), - RadrootsTransportError::RequiredTargetNotRequested - ); - - let forged_payload = RadrootsTransportPayload::OpaqueBytes { - label: "opaque".to_owned(), - bytes: vec![1], - digest: "0".repeat(64), - }; - assert_eq!( - RadrootsTransportDeliveryRequest::new( - "request", - forged_payload, - targets.clone(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect_err("forged payload"), - RadrootsTransportError::PayloadDigestMismatch - ); - - let request = RadrootsTransportDeliveryRequest::new( - "request", - payload, - targets.clone(), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect("request"); - assert_eq!( - request - .clone() - .try_with_now_ms(-1) - .expect_err("negative timestamp"), - RadrootsTransportError::InvalidDeliveryTimestamp - ); - - let accepted_first = RadrootsTransportTargetReceipt::new( - first.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ); - let accepted_second = RadrootsTransportTargetReceipt::new( - second.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ); - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![accepted_first.clone()], - ) - .expect_err("missing receipt"), - RadrootsTransportError::MissingDeliveryTargetReceipt - ); - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![accepted_first.clone(), accepted_first.clone()], - ) - .expect_err("duplicate receipt"), - RadrootsTransportError::DuplicateDeliveryTargetReceipt - ); - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![ - accepted_first.clone(), - RadrootsTransportTargetReceipt::new( - unexpected.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ), - ], - ) - .expect_err("unexpected receipt"), - RadrootsTransportError::UnexpectedDeliveryTargetReceipt - ); - let mut mismatched_status = accepted_second.clone(); - mismatched_status.status = RadrootsTransportDeliveryTargetStatus::Pending; - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![accepted_first.clone(), mismatched_status], - ) - .expect_err("status mismatch"), - RadrootsTransportError::DeliveryTargetReceiptStatusMismatch - ); - let mut forged_outcome = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted); - forged_outcome.status = RadrootsTransportDeliveryTargetStatus::Pending; - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![ - accepted_first.clone(), - RadrootsTransportTargetReceipt { - target: second.clone(), - attempted: true, - status: RadrootsTransportDeliveryTargetStatus::Pending, - outcome: forged_outcome, - }, - ], - ) - .expect_err("outcome status mismatch"), - RadrootsTransportError::TransportOutcomeStatusMismatch - ); - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![ - accepted_first.clone(), - RadrootsTransportTargetReceipt::skipped( - second.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ), - ], - ) - .expect_err("skipped accepted outcome"), - RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch - ); - - let receipt = RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![accepted_second, accepted_first], - ) - .expect("canonical receipt"); - assert_eq!(receipt.target_receipts()[0].target, first); - assert_eq!(receipt.target_receipts()[1].target, second); - receipt - .validate_for_request(&request) - .expect("matching request"); - - let wrong_id = RadrootsTransportDeliveryReceipt::new( - "other-request", - targets, - receipt.target_receipts().to_vec(), - ) - .expect("wrong id receipt"); - assert_eq!( - wrong_id - .validate_for_request(&request) - .expect_err("request id mismatch"), - RadrootsTransportError::DeliveryReceiptRequestIdMismatch - ); - let wrong_targets = RadrootsTransportDeliveryReceipt::new( - "request", - TargetSet::new(vec![unexpected.clone()]).expect("unexpected target set"), - vec![RadrootsTransportTargetReceipt::new( - unexpected, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - )], - ) - .expect("wrong target receipt"); - assert_eq!( - wrong_targets - .validate_for_request(&request) - .expect_err("target set mismatch"), - RadrootsTransportError::DeliveryReceiptTargetSetMismatch - ); -} - -#[test] -#[cfg(feature = "serde")] -fn delivery_request_and_receipt_deserialization_revalidates_invariants() { - let target = Target::nostr_relay("wss://relay.example").expect("target"); - let request = RadrootsTransportDeliveryRequest::new( - "request", - opaque_payload(), - TargetSet::new(vec![target.clone()]).expect("target set"), - RadrootsTransportSatisfactionPolicy::all_accepted(), - ) - .expect("request"); - let mut request_wire = serde_json::to_value(&request).expect("request wire"); - request_wire["now_ms"] = Value::from(-1); - assert!(serde_json::from_value::<RadrootsTransportDeliveryRequest>(request_wire).is_err()); - - let receipt = RadrootsTransportDeliveryReceipt::for_request( - &request, - vec![RadrootsTransportTargetReceipt::new( - target, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - )], - ) - .expect("receipt"); - let mut receipt_wire = serde_json::to_value(&receipt).expect("receipt wire"); - receipt_wire["target_receipts"][0]["status"] = Value::String("Pending".to_owned()); - assert!(serde_json::from_value::<RadrootsTransportDeliveryReceipt>(receipt_wire).is_err()); - let mut attempt_wire = serde_json::to_value(&receipt).expect("receipt wire"); - attempt_wire["target_receipts"][0]["attempted"] = Value::Bool(false); - assert!(serde_json::from_value::<RadrootsTransportDeliveryReceipt>(attempt_wire).is_err()); - let mut direct_target_receipt_wire = - serde_json::to_value(&receipt.target_receipts()[0]).expect("target receipt wire"); - direct_target_receipt_wire["status"] = Value::String("Pending".to_owned()); - assert!( - serde_json::from_value::<RadrootsTransportTargetReceipt>(direct_target_receipt_wire) - .is_err() - ); - let mut direct_attempt_wire = - serde_json::to_value(&receipt.target_receipts()[0]).expect("target receipt wire"); - direct_attempt_wire["attempted"] = Value::Bool(false); - assert!(serde_json::from_value::<RadrootsTransportTargetReceipt>(direct_attempt_wire).is_err()); - - let mut payload_wire = serde_json::to_value(opaque_payload()).expect("payload wire"); - payload_wire["OpaqueBytes"]["digest"] = Value::String("0".repeat(64)); - assert!(serde_json::from_value::<RadrootsTransportPayload>(payload_wire).is_err()); - - let first = Target::nostr_relay("wss://one.example").expect("first target"); - let second = Target::nostr_relay("wss://two.example").expect("second target"); - let mut reversed = vec![first.fingerprint().clone(), second.fingerprint().clone()]; - reversed.sort(); - reversed.reverse(); - let required_wire = serde_json::json!({ - "RequiredTargets": { - "class": "Accepted", - "targets": reversed, - } - }); - let decoded_required = - serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(required_wire) - .expect("decode required policy"); - let required_targets = decoded_required - .required_target_fingerprints() - .expect("required fingerprints"); - assert!(required_targets.windows(2).all(|pair| pair[0] < pair[1])); - assert!( - serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(serde_json::json!({ - "RequiredTargets": { - "class": "Accepted", - "targets": [], - } - })) - .is_err() - ); - assert!( - serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(serde_json::json!({ - "Quorum": { - "class": "Accepted", - "threshold": 0, - } - })) - .is_err() - ); -} - -#[test] -fn payload_contract_covers_all_validation_boundaries() { - let signed = RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "{}") - .expect("signed"); - let mesh = RadrootsTransportPayload::mesh_frame_cbor("mesh", [1]).expect("mesh"); - let opaque = RadrootsTransportPayload::opaque_bytes(" label ", [2]).expect("opaque"); - assert_eq!(signed.payload_kind(), "signed_event_json"); - assert_eq!(mesh.payload_kind(), "mesh_frame_cbor"); - assert_eq!(opaque.payload_kind(), "opaque_bytes"); - - for invalid_id in ["a".repeat(63), "g".repeat(64)] { - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json(invalid_id, "{}") - .expect_err("invalid event id"), - RadrootsTransportError::InvalidPayloadId - ); - } - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor("", [1]).expect_err("empty message id"), - RadrootsTransportError::EmptyPayloadId - ); - for invalid_id in [" mesh", "mesh/one", "mesh\n"] { - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor(invalid_id, [1]) - .expect_err("invalid token id"), - RadrootsTransportError::InvalidPayloadId - ); - } - assert_eq!( - RadrootsTransportPayload::opaque_bytes(" ", [1]).expect_err("empty label"), - RadrootsTransportError::EmptyPayloadLabel - ); - assert_eq!( - RadrootsTransportPayload::opaque_bytes("label", []).expect_err("empty opaque bytes"), - RadrootsTransportError::EmptyPayloadBytes - ); - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "") - .expect_err("empty raw json"), - RadrootsTransportError::EmptyPayloadBytes - ); - for invalid_json in [" {}", "{} ", "{\n}", "[]", "{", "}"] { - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), invalid_json) - .expect_err("invalid raw json"), - RadrootsTransportError::InvalidPayloadBytes - ); - } - for invalid_digest in ["f".repeat(63), "g".repeat(64)] { - assert_eq!( - RadrootsTransportPayload::opaque_bytes_with_digest("label", [1], invalid_digest) - .expect_err("invalid digest"), - RadrootsTransportError::InvalidPayloadDigest - ); - } - - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json_with_digest( - "bad", - "{}", - "f".repeat(64), - ) - .expect_err("invalid signed event before digest"), - RadrootsTransportError::InvalidPayloadId - ); - assert_eq!( - RadrootsTransportPayload::unchecked_signed_event_json_with_digest( - "a".repeat(64), - "{}", - "bad", - ) - .expect_err("invalid signed digest"), - RadrootsTransportError::InvalidPayloadDigest - ); - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor_with_digest("", [1], "f".repeat(64)) - .expect_err("invalid mesh before digest"), - RadrootsTransportError::EmptyPayloadId - ); - assert_eq!( - RadrootsTransportPayload::mesh_frame_cbor_with_digest("mesh", [1], "bad") - .expect_err("invalid mesh digest"), - RadrootsTransportError::InvalidPayloadDigest - ); - assert_eq!( - RadrootsTransportPayload::opaque_bytes_with_digest("", [1], "f".repeat(64)) - .expect_err("invalid opaque payload before digest"), - RadrootsTransportError::EmptyPayloadLabel - ); -} - -#[test] -fn status_contract_covers_builders_and_availability_defaults() { - assert_eq!( - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) - .with_code("accepted") - .with_message("accepted by transport") - .code - .as_deref(), - Some("accepted") - ); - - assert!(!RadrootsTransportCapabilities::none().deliver); - assert!(RadrootsTransportCapabilities::fetch_only().fetch); - assert_eq!( - RadrootsTransportCapabilities::reticulum_unavailable(), - RadrootsTransportCapabilities::none() - ); - let capabilities = RadrootsTransportCapabilities::deliver_and_fetch() - .with_discovery(true) - .with_gateway_forwarding(true) - .with_receipt_observation(true); - assert!(capabilities.deliver); - assert!(capabilities.fetch); - assert!(capabilities.discovery); - assert!(capabilities.gateway_forwarding); - assert!(capabilities.receipt_observation); - - let unavailable = RadrootsTransportStatus::new( - TransportId::RETICULUM, - true, - RadrootsTransportImplementationState::Mock, - false, - "unavailable", - ) - .with_capabilities(capabilities.clone()) - .with_maturity(RadrootsTransportCapabilityMaturity::Preview) - .with_availability(RadrootsTransportCapabilityAvailability::Degraded) - .with_profile_id("reticulum.local") - .with_endpoint_uri("reticulum:local"); - assert_eq!( - unavailable.availability, - RadrootsTransportCapabilityAvailability::Degraded - ); - assert_eq!( - unavailable.maturity, - RadrootsTransportCapabilityMaturity::Preview - ); - assert_eq!(unavailable.capabilities, capabilities); - assert!(!unavailable.usable_for_delivery); - - assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_ready_for_attempt()); - assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_retryable_failure()); - assert!(RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied.is_terminal_failure()); - assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_terminal_failure()); - assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_deferred_until_implemented()); -} - -#[test] -#[cfg(feature = "serde")] -fn transport_kind_deserializer_rejects_non_string_values() { - assert!(serde_json::from_str::<TransportId>("1").is_err()); - assert!(serde_json::from_str::<TransportId>("\"NOSTR\"").is_err()); -} - -#[test] -#[cfg(feature = "serde")] -fn transport_id_serde_uses_the_protocol_wire_contract() { - let id = TransportId::parse("future-mesh-v3").expect("future transport id"); - let encoded = serde_json::to_string(&id).expect("serialize transport id"); - assert_eq!(encoded, "\"future-mesh-v3\""); - assert_eq!(serde_json::from_str::<TransportId>(&encoded).unwrap(), id); - - let protocol: radroots_protocol::capability::v1::TransportKind = id.into(); - assert_eq!(protocol.as_str(), id.as_str()); - assert_eq!(TransportId::from(protocol), id); -} - -#[test] -#[cfg(feature = "serde")] -fn transport_target_deserialization_rejects_forged_and_noncanonical_identity() { - let target = reticulum_target(); - let canonical = serde_json::to_value(&target).expect("serialize target"); - assert_eq!( - serde_json::from_value::<Target>(canonical.clone()).expect("deserialize canonical target"), - target - ); - - for (field, forged) in [ - ("uri", Value::String("reticulum:other".to_owned())), - ("scope", Value::Null), - ("fingerprint", Value::String("0".repeat(64))), - ( - "fingerprint", - Value::String(target.fingerprint().as_str().to_ascii_uppercase()), - ), - ] { - let mut wire = canonical.clone(); - wire.as_object_mut() - .expect("target object") - .insert(field.to_owned(), forged); - assert!( - serde_json::from_value::<Target>(wire).is_err(), - "forged {field} must fail" - ); - } - - let labeled = Target::nostr_relay_with_metadata( - "wss://relay.example", - None, - Some(TargetLabel::parse("Relay").expect("label")), - ) - .expect("labeled target"); - let mut noncanonical_label = serde_json::to_value(&labeled).expect("serialize labeled target"); - noncanonical_label - .as_object_mut() - .expect("target object") - .insert("label".to_owned(), Value::String(" Relay ".to_owned())); - assert!(serde_json::from_value::<Target>(noncanonical_label).is_err()); - - let mut unknown_field = canonical; - unknown_field - .as_object_mut() - .expect("target object") - .insert("unexpected".to_owned(), Value::Bool(true)); - assert!(serde_json::from_value::<Target>(unknown_field).is_err()); -} - -#[test] -#[cfg(feature = "serde")] -fn transport_target_set_deserialization_revalidates_nonempty_unique_targets() { - let target = reticulum_target(); - let set = TargetSet::new(vec![target.clone()]).expect("target set"); - let canonical = serde_json::to_value(&set).expect("serialize target set"); - assert_eq!( - serde_json::from_value::<TargetSet>(canonical).expect("deserialize canonical target set"), - set - ); - assert!(serde_json::from_value::<TargetSet>(serde_json::json!({ "targets": [] })).is_err()); - assert!( - serde_json::from_value::<TargetSet>(serde_json::json!({ "targets": [target, target] })) - .is_err() - ); -} - -#[test] -fn target_contract_covers_parser_and_authority_boundaries() { - let scope = TargetScope::parse("farm_1.alpha-beta").expect("scope"); - assert_eq!(scope.as_str(), "farm_1.alpha-beta"); - assert_eq!(scope.to_string(), "farm_1.alpha-beta"); - for invalid_scope in [" scope", "scope ", "scope/path", "scope\n"] { - assert_eq!( - TargetScope::parse(invalid_scope).expect_err("invalid scope"), - RadrootsTransportError::InvalidTargetScope - ); - } - - let label = TargetLabel::parse(" Relay One ").expect("label"); - assert_eq!(label.as_str(), "Relay One"); - assert_eq!(label.to_string(), "Relay One"); - assert_eq!( - TargetLabel::parse("\u{7f}").expect_err("control label"), - RadrootsTransportError::InvalidTargetLabel - ); - - for invalid in [ - "", - " wss://relay.example", - "wss://relay example", - "wss://relay\u{7f}.example", - "relay.example", - "ftp://relay.example", - "wss://[::1", - "wss://[]", - "wss://[::1]suffix", - "wss://[[::1]]", - "wss://relay[.example", - "wss://.relay.example", - "wss://relay..example", - "wss://relay.example.", - "wss://relay_example", - "wss://-relay.example", - "wss://relay-.example", - "wss://xn--fa-hia.example", - "wss://example.999", - "wss://example.0x1", - "wss://%65xample.com", - "wss://127.1", - "wss://2130706433", - "wss://0x7f.1", - "wss://01.2.3.4", - "wss://256.1.1.1", - "wss://:443", - "wss://relay.example:", - "wss://relay.example:0", - "wss://relay.example:01", - "wss://[::1]:", - "wss://[::1]:bad", - "wss://[::1]:42949672960", - "wss://[not-ipv6]", - "wss://relay.example\\path", - "wss://relay.example/[raw]", - "wss://relay.example/%", - "wss://relay.example/%2", - "wss://relay.example/%2f", - "wss://relay.example/%GG", - "wss://relay.example/a/./b", - "wss://relay.example/a/../b", - "wss://relay.example/a/%2E/b", - "wss://relay.example/a/.%2E/b", - "ws://[2001:db8::1]", - ] { - assert_eq!( - Target::nostr_relay(invalid).expect_err("invalid relay URI"), - if invalid.is_empty() { - RadrootsTransportError::EmptyTargetUri - } else { - RadrootsTransportError::InvalidTargetUri - }, - "{invalid}" - ); - } - - for (raw, canonical) in [ - ("WSS://[2001:0DB8:0:0:0:0:0:1]", "wss://[2001:db8::1]"), - ("wss://relay.example:443/", "wss://relay.example"), - ( - "wss://relay.example/nostr/%2Ffeed", - "wss://relay.example/nostr/%2Ffeed", - ), - ("ws://127.0.0.1", "ws://127.0.0.1"), - ] { - assert_eq!( - Target::nostr_relay(raw).expect("relay URI").uri().as_str(), - canonical - ); - } -} - -#[test] -fn every_transport_error_has_a_stable_display_message() { - let remaining = [ - RadrootsTransportError::RequiredTargetNotRequested, - RadrootsTransportError::EmptyFetchRequestId, - RadrootsTransportError::InvalidFetchRequestId, - RadrootsTransportError::InvalidFetchLimit, - RadrootsTransportError::InvalidFetchDeadline, - RadrootsTransportError::EmptyFetchCursor, - RadrootsTransportError::InvalidFetchCursor, - RadrootsTransportError::InvalidObservedAt, - RadrootsTransportError::UnexpectedFetchProvenance, - RadrootsTransportError::UnexpectedFetchTargetOutcome, - RadrootsTransportError::DuplicateFetchTargetOutcome, - RadrootsTransportError::FetchPageLimitExceeded, - RadrootsTransportError::FetchPageRequestMismatch, - RadrootsTransportError::EmptyDeliveryRequestId, - RadrootsTransportError::InvalidDeliveryRequestId, - RadrootsTransportError::InvalidDeliveryTimestamp, - RadrootsTransportError::InvalidDeliveryDeadline, - RadrootsTransportError::InvalidDeliveryOutcome, - RadrootsTransportError::UnexpectedDeliveryTargetReceipt, - RadrootsTransportError::DuplicateDeliveryTargetReceipt, - RadrootsTransportError::MissingDeliveryTargetReceipt, - RadrootsTransportError::DeliveryTargetReceiptStatusMismatch, - RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch, - RadrootsTransportError::TransportOutcomeStatusMismatch, - RadrootsTransportError::DeliveryReceiptRequestIdMismatch, - RadrootsTransportError::DeliveryReceiptTargetSetMismatch, - RadrootsTransportError::EmptyPayloadId, - RadrootsTransportError::InvalidPayloadId, - RadrootsTransportError::EmptyPayloadLabel, - RadrootsTransportError::InvalidPayloadLabel, - RadrootsTransportError::EmptyPayloadBytes, - RadrootsTransportError::InvalidPayloadBytes, - RadrootsTransportError::InvalidPayloadDigest, - RadrootsTransportError::PayloadDigestMismatch, - ]; - for error in remaining { - assert!(!error.to_string().is_empty()); - } -} diff --git a/crates/transport_reticulum/Cargo.toml b/crates/transport_reticulum/Cargo.toml @@ -21,6 +21,7 @@ serde = { workspace = true, optional = true, features = ["derive"] } [dev-dependencies] futures = { workspace = true } +radroots_event = { workspace = true, features = ["std", "serde"] } serde_json = { workspace = true, features = ["std"] } [lints.rust] diff --git a/crates/transport_reticulum/src/contract.rs b/crates/transport_reticulum/src/contract.rs @@ -1,7 +1,7 @@ use crate::RADROOTS_RETICULUM_ENDPOINT_URI; use radroots_transport::target::EndpointUri; use radroots_transport::target::{TargetFingerprint, TargetLabel, TargetScope}; -use radroots_transport::{RadrootsTransportError, Target, TransportId}; +use radroots_transport::{Error as TransportError, Target, TransportId}; pub const RETICULUM_V1_MAX_PAYLOAD_BYTES: usize = 64 * 1024; @@ -115,7 +115,10 @@ impl ReticulumDestinationV1 { uri: impl AsRef<str>, scope: TargetScope, label: Option<TargetLabel>, - ) -> Result<Self, RadrootsTransportError> { + ) -> Result<Self, TransportError> { + if uri.as_ref() != RADROOTS_RETICULUM_ENDPOINT_URI { + return Err(TransportError::InvalidTargetUri); + } let target = Target::new_with_metadata( TransportId::RETICULUM, uri.as_ref(), @@ -137,23 +140,23 @@ impl ReticulumDestinationV1 { }) } - pub fn from_target(target: &Target) -> Result<Self, RadrootsTransportError> { + pub fn from_target(target: &Target) -> Result<Self, TransportError> { if target.kind() != &TransportId::RETICULUM || target.uri().as_str() != RADROOTS_RETICULUM_ENDPOINT_URI { - return Err(RadrootsTransportError::InvalidTargetUri); + return Err(TransportError::InvalidTargetUri); } let Some(scope) = target.scope().cloned() else { - return Err(RadrootsTransportError::EmptyTargetScope); + return Err(TransportError::EmptyTargetScope); }; let destination = Self::new(target.uri().as_str(), scope, target.label().cloned())?; if destination.fingerprint != *target.fingerprint() { - return Err(RadrootsTransportError::InvalidTargetFingerprint); + return Err(TransportError::InvalidTargetFingerprint); } Ok(destination) } - pub fn transport_target(&self) -> Result<Target, RadrootsTransportError> { + pub fn transport_target(&self) -> Result<Target, TransportError> { Target::new_with_metadata( TransportId::RETICULUM, self.uri.as_str(), diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -32,19 +32,11 @@ use radroots_transport::sink::{ }; use radroots_transport::source::{EventSource, FetchPage, FetchRequest, NextPage, SourceStatus}; use radroots_transport::target::TargetScope; -use radroots_transport::{ - RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, - RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt, - RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportImplementationState, - RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportStatus, - RadrootsTransportTargetReceipt, Target, TransportId, -}; +use radroots_transport::{Error as TransportError, Target, TransportId}; const DEFAULT_PROFILE_ID: &str = "transport.reticulum.default"; const RETICULUM_AGENT_ENDPOINT_PREFIX: &str = "reticulum-agent:"; const UNAVAILABLE_CODE: &str = "transport_unavailable"; -const DEFERRED_CODE: &str = "deferred_until_implemented"; -const DEFERRED_MESSAGE: &str = "Reticulum delivery is deferred until implementation"; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -223,28 +215,6 @@ impl RadrootsReticulumProfile { pub fn capability_report(&self) -> &ReticulumCapabilityReportV1 { &self.capability_report } - - pub fn status(&self) -> RadrootsReticulumStatus { - RadrootsReticulumStatus { - behavior: self.behavior, - scope: self.scope.clone(), - agent_endpoint: self.agent_endpoint.clone(), - destination: self.destination.clone(), - capability_report: self.capability_report.clone(), - transport_status: RadrootsTransportStatus::new( - TransportId::RETICULUM, - true, - RadrootsTransportImplementationState::Real, - false, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - ) - .with_maturity(RadrootsTransportCapabilityMaturity::Preview) - .with_availability(RadrootsTransportCapabilityAvailability::Unavailable) - .with_capabilities(RadrootsTransportCapabilities::reticulum_unavailable()) - .with_profile_id(self.profile_id.clone()) - .with_endpoint_uri(self.endpoint.as_str()), - } - } } impl Default for RadrootsReticulumProfile { @@ -253,17 +223,6 @@ impl Default for RadrootsReticulumProfile { } } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsReticulumStatus { - pub behavior: RadrootsReticulumBehavior, - pub scope: TargetScope, - pub agent_endpoint: Option<RadrootsReticulumAgentEndpoint>, - pub destination: ReticulumDestinationV1, - pub capability_report: ReticulumCapabilityReportV1, - pub transport_status: RadrootsTransportStatus, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsReticulumTransport { profile: RadrootsReticulumProfile, @@ -277,45 +236,6 @@ impl RadrootsReticulumTransport { pub fn profile(&self) -> &RadrootsReticulumProfile { &self.profile } - - pub fn status(&self) -> RadrootsReticulumStatus { - self.profile.status() - } - - pub fn deliver( - &self, - request: RadrootsTransportDeliveryRequest, - ) -> Result<RadrootsTransportDeliveryReceipt, RadrootsReticulumError> { - ensure_reticulum_targets(request.target_set().targets())?; - let outcome = reticulum_outcome(self.profile.behavior); - let target_receipts = request - .target_set() - .targets() - .iter() - .cloned() - .map(|target| RadrootsTransportTargetReceipt::skipped(target, outcome.clone())) - .collect::<Vec<_>>(); - RadrootsTransportDeliveryReceipt::for_request(&request, target_receipts) - .map_err(|_| RadrootsReticulumError::InvalidDeliveryReceipt) - } - - pub fn fetch( - &self, - request: RadrootsReticulumFetchRequest, - ) -> Result<RadrootsReticulumFetchReceipt, RadrootsReticulumError> { - if request.max_events == 0 { - return Err(RadrootsReticulumError::InvalidFetchLimit); - } - Ok(RadrootsReticulumFetchReceipt { - request_id: request.request_id, - endpoint_uri: self.profile.endpoint.as_str().to_owned(), - scope: self.profile.scope.clone(), - agent_endpoint: self.profile.agent_endpoint.clone(), - outcome: reticulum_outcome(self.profile.behavior), - observed_event_count: 0, - implementation: RadrootsTransportImplementationState::Real, - }) - } } impl Default for RadrootsReticulumTransport { @@ -325,9 +245,7 @@ impl Default for RadrootsReticulumTransport { } impl EventSink for RadrootsReticulumTransport { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, RadrootsTransportError>> { + fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { Box::pin(async { Ok(SinkStatus::new( TransportId::RETICULUM, @@ -366,9 +284,7 @@ impl EventSink for RadrootsReticulumTransport { } impl EventSource for RadrootsReticulumTransport { - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, RadrootsTransportError>> { + fn status(&self) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> { Box::pin(async { Ok(SourceStatus::new( TransportId::RETICULUM, @@ -384,7 +300,7 @@ impl EventSource for RadrootsReticulumTransport { fn fetch( &self, request: FetchRequest, - ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, RadrootsTransportError>> { + ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> { Box::pin(async move { ensure_reticulum_targets(request.target_set().targets()) .map_err(reticulum_error_to_transport_error)?; @@ -405,48 +321,12 @@ impl EventSource for RadrootsReticulumTransport { } } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsReticulumFetchRequest { - pub request_id: String, - pub max_events: u16, -} - -impl RadrootsReticulumFetchRequest { - pub fn new( - request_id: impl Into<String>, - max_events: u16, - ) -> Result<Self, RadrootsReticulumError> { - if max_events == 0 { - return Err(RadrootsReticulumError::InvalidFetchLimit); - } - Ok(Self { - request_id: request_id.into(), - max_events, - }) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsReticulumFetchReceipt { - pub request_id: String, - pub endpoint_uri: String, - pub scope: TargetScope, - pub agent_endpoint: Option<RadrootsReticulumAgentEndpoint>, - pub outcome: RadrootsTransportOutcome, - pub observed_event_count: usize, - pub implementation: RadrootsTransportImplementationState, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsReticulumError { InvalidEndpoint, InvalidAgentEndpoint, InvalidProfileId, - InvalidFetchLimit, NonReticulumTarget, - InvalidDeliveryReceipt, } impl fmt::Display for RadrootsReticulumError { @@ -455,23 +335,18 @@ impl fmt::Display for RadrootsReticulumError { Self::InvalidEndpoint => "invalid Reticulum endpoint", Self::InvalidAgentEndpoint => "invalid Reticulum agent endpoint", Self::InvalidProfileId => "invalid Reticulum profile id", - Self::InvalidFetchLimit => "Reticulum fetch limit must be greater than zero", Self::NonReticulumTarget => "Reticulum transport received a non-Reticulum target", - Self::InvalidDeliveryReceipt => "Reticulum transport produced an invalid receipt", }) } } -fn reticulum_error_to_transport_error(error: RadrootsReticulumError) -> RadrootsTransportError { +fn reticulum_error_to_transport_error(error: RadrootsReticulumError) -> TransportError { match error { RadrootsReticulumError::InvalidEndpoint | RadrootsReticulumError::NonReticulumTarget => { - RadrootsTransportError::InvalidTargetUri + TransportError::InvalidTargetUri } - RadrootsReticulumError::InvalidAgentEndpoint - | RadrootsReticulumError::InvalidProfileId - | RadrootsReticulumError::InvalidFetchLimit - | RadrootsReticulumError::InvalidDeliveryReceipt => { - RadrootsTransportError::InvalidTransportKind + RadrootsReticulumError::InvalidAgentEndpoint | RadrootsReticulumError::InvalidProfileId => { + TransportError::InvalidTransportKind } } } @@ -491,129 +366,63 @@ fn ensure_reticulum_targets(targets: &[Target]) -> Result<(), RadrootsReticulumE Ok(()) } -fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOutcome { - let mut outcome = match behavior { - RadrootsReticulumBehavior::RejectDeliveryAttempts => { - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) - } - RadrootsReticulumBehavior::DeferDeliveryPlans => { - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) - } - }; - outcome.code = Some( - match behavior { - RadrootsReticulumBehavior::RejectDeliveryAttempts => UNAVAILABLE_CODE, - RadrootsReticulumBehavior::DeferDeliveryPlans => DEFERRED_CODE, - } - .to_owned(), - ); - outcome.message = Some( - match behavior { - RadrootsReticulumBehavior::RejectDeliveryAttempts => { - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE - } - RadrootsReticulumBehavior::DeferDeliveryPlans => DEFERRED_MESSAGE, - } - .to_owned(), - ); - outcome -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; - use alloc::format; - use alloc::string::ToString; - use alloc::vec; + use alloc::{format, vec}; use futures::executor::block_on; + use radroots_event::{SignedEvent, wire::Nip01EventWire}; use radroots_transport::{ - RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, TargetSet, + DeliveryRequest, EventSink, EventSource, FetchRequest, TargetSet, + outcome::DeliveryOutcomeKind, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::DeliveryPayload, + source::FetchBounds, }; - fn reticulum_target() -> Target { + fn target() -> Target { ReticulumDestinationV1::local() .transport_target() .expect("Reticulum target") } - fn delivery_request(targets: Vec<Target>) -> RadrootsTransportDeliveryRequest { - RadrootsTransportDeliveryRequest::new( - "delivery", - RadrootsTransportPayload::mesh_frame_cbor("message", [1_u8, 2, 3]) - .expect("mesh payload"), - TargetSet::new(targets).expect("target set"), - RadrootsTransportSatisfactionPolicy::any_accepted(), - ) - .expect("delivery request") + fn signed_event() -> SignedEvent { + let mut wire = Nip01EventWire { + id: "0".repeat(64), + pubkey: "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df".to_owned(), + created_at: 1_800_000_100, + kind: 1, + tags: vec![], + content: "reticulum-preview".to_owned(), + sig: "42".repeat(64), + extra: Default::default(), + }; + wire.id = wire.computed_event_id().expect("event id").to_hex(); + let raw = format!( + "{{\"id\":\"{}\",\"pubkey\":\"{}\",\"created_at\":{},\"kind\":{},\"tags\":[],\"content\":\"{}\",\"sig\":\"{}\"}}", + wire.id, wire.pubkey, wire.created_at, wire.kind, wire.content, wire.sig + ); + SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") } #[test] - #[allow(clippy::unnecessary_to_owned)] - fn endpoint_profile_and_fetch_models_cover_owned_and_borrowed_boundaries() { - let endpoint = - RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI).expect("endpoint"); + fn endpoints_and_profiles_preserve_validated_reticulum_contracts() { + let endpoint = RadrootsReticulumEndpoint::default(); assert_eq!(endpoint.as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); assert_eq!(format!("{endpoint}"), RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!( - endpoint.clone().into_string(), - RADROOTS_RETICULUM_ENDPOINT_URI - ); - assert_eq!( - RadrootsReticulumEndpoint::default(), - RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI.to_string()) - .expect("owned endpoint") - ); assert!(RadrootsReticulumEndpoint::parse("reticulum:other").is_err()); - for invalid in [ - "", - " reticulum-agent:local", - "reticulum-agent:local ", - "reticulum-agent:\tlocal", - "other:local", - RETICULUM_AGENT_ENDPOINT_PREFIX, - ] { - assert!(RadrootsReticulumAgentEndpoint::parse(invalid).is_err()); - } let agent = RadrootsReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint"); - assert_eq!(agent.as_str(), "reticulum-agent:local"); - assert_eq!(format!("{agent}"), "reticulum-agent:local"); - assert_eq!(agent.clone().into_string(), "reticulum-agent:local"); - assert_eq!( - RadrootsReticulumAgentEndpoint::parse("reticulum-agent:owned".to_string()) - .expect("owned agent") - .as_str(), - "reticulum-agent:owned" - ); - - let scope = TargetScope::parse("farm.mesh").expect("scope"); - for invalid in ["", " ", "profile id"] { - assert!( - RadrootsReticulumProfile::new( - invalid, - endpoint.clone(), - scope.clone(), - None, - RadrootsReticulumBehavior::RejectDeliveryAttempts, - ) - .is_err() - ); - } let profile = RadrootsReticulumProfile::new( - "transport.reticulum.farm".to_string(), - endpoint.clone(), - scope.clone(), + "transport.reticulum.farm", + endpoint, + TargetScope::parse("farm.mesh").expect("scope"), Some(agent.clone()), - RadrootsReticulumBehavior::RejectDeliveryAttempts, + RadrootsReticulumBehavior::DeferDeliveryPlans, ) - .expect("profile") - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans) - .with_agent_endpoint(agent.clone()); - assert_eq!(profile.profile_id(), "transport.reticulum.farm"); - assert_eq!(profile.endpoint(), &endpoint); - assert_eq!(profile.scope(), &scope); + .expect("profile"); assert_eq!(profile.agent_endpoint(), Some(&agent)); assert_eq!( profile.behavior(), @@ -623,120 +432,60 @@ mod tests { profile.destination(), &profile.capability_report().destination ); - assert_eq!(profile.status().behavior, profile.behavior()); - - let default_profile = RadrootsReticulumProfile::deferred_until_implemented(); - assert_eq!(default_profile, RadrootsReticulumProfile::default()); - assert!(default_profile.agent_endpoint().is_none()); - - assert!(RadrootsReticulumFetchRequest::new("invalid", 0).is_err()); - let fetch = - RadrootsReticulumFetchRequest::new("fetch".to_string(), 1).expect("fetch request"); - assert_eq!(fetch.request_id, "fetch"); } #[test] - fn transport_facades_and_private_guards_cover_all_contract_outcomes() { - let rejecting = RadrootsReticulumTransport::default(); - assert_eq!( - rejecting.profile().behavior(), - RadrootsReticulumBehavior::RejectDeliveryAttempts - ); - assert_eq!(rejecting.status(), rejecting.profile().status()); - let receipt = rejecting - .deliver(delivery_request(vec![reticulum_target()])) - .expect("direct delivery"); + fn canonical_source_and_sink_fail_closed_with_request_bound_evidence() { + let transport = RadrootsReticulumTransport::default(); + let sink_status = block_on(EventSink::status(&transport)).expect("sink status"); + let source_status = block_on(EventSource::status(&transport)).expect("source status"); + assert_eq!(sink_status.transport_id(), TransportId::RETICULUM); + assert_eq!(source_status.transport_id(), TransportId::RETICULUM); + assert_eq!(sink_status.availability(), Availability::Unavailable); + assert_eq!(source_status.availability(), Availability::Unavailable); + + let targets = TargetSet::new(vec![target()]).expect("targets"); + let delivery = DeliveryRequest::new( + "reticulum-delivery", + DeliveryPayload::new(signed_event()), + targets.clone(), + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + 1_800_000_200_000, + ) + .expect("delivery request"); + let receipt = block_on(EventSink::deliver(&transport, delivery.clone())) + .expect("unavailable receipt"); assert_eq!(receipt.target_receipts().len(), 1); + assert!(!receipt.target_receipts()[0].was_attempted()); assert_eq!( - rejecting - .fetch(RadrootsReticulumFetchRequest::new("direct-fetch", 1).expect("fetch")) - .expect("direct fetch") - .observed_event_count, - 0 - ); - assert!( - rejecting - .fetch(RadrootsReticulumFetchRequest { - request_id: "invalid-fetch".to_owned(), - max_events: 0, - }) - .is_err() - ); - - assert_eq!( - block_on(EventSink::status(&rejecting)) - .expect("sink status") - .transport_id(), - TransportId::RETICULUM + receipt.target_receipts()[0].outcome().kind(), + DeliveryOutcomeKind::Unavailable ); - assert_eq!( - block_on(EventSource::status(&rejecting)) - .expect("source status") - .transport_id(), - TransportId::RETICULUM - ); - - let deferring = RadrootsReticulumTransport::new( - RadrootsReticulumProfile::default() - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans), - ); - assert_eq!( - deferring - .deliver(delivery_request(vec![reticulum_target()])) - .expect("deferred delivery") - .target_receipts()[0] - .outcome - .kind, - RadrootsTransportOutcomeKind::DeferredUntilImplemented - ); - - assert_eq!( - reticulum_error_to_transport_error(RadrootsReticulumError::InvalidEndpoint), - RadrootsTransportError::InvalidTargetUri - ); - assert_eq!( - reticulum_error_to_transport_error(RadrootsReticulumError::NonReticulumTarget), - RadrootsTransportError::InvalidTargetUri - ); - for error in [ - RadrootsReticulumError::InvalidAgentEndpoint, - RadrootsReticulumError::InvalidProfileId, - RadrootsReticulumError::InvalidFetchLimit, - ] { - assert_eq!( - reticulum_error_to_transport_error(error), - RadrootsTransportError::InvalidTransportKind - ); - } + receipt + .validate_for_request(&delivery) + .expect("request bound"); + + let fetch = FetchRequest::new( + "reticulum-fetch", + targets, + FetchBounds::new(10, 1_800_000_200_000).expect("bounds"), + ) + .expect("fetch request"); + let page = block_on(EventSource::fetch(&transport, fetch.clone())).expect("fetch page"); + assert!(page.events().is_empty()); + page.validate_for_request(&fetch).expect("request bound"); + } - assert!(ensure_reticulum_targets(&[]).is_ok()); - let wrong_kind = - Target::new(TransportId::LOCAL, "local:memory").expect("non-Reticulum target"); + #[test] + fn non_reticulum_targets_are_rejected_before_adapter_effects() { + let target = Target::nostr_relay("wss://relay.example").expect("Nostr target"); assert_eq!( - ensure_reticulum_targets(&[wrong_kind]), + ensure_reticulum_targets(&[target]), Err(RadrootsReticulumError::NonReticulumTarget) ); - let wrong_uri = Target::new(TransportId::RETICULUM, "reticulum:other") - .expect("transport-neutral target"); assert_eq!( - ensure_reticulum_targets(&[wrong_uri]), - Err(RadrootsReticulumError::InvalidEndpoint) - ); - let missing_scope = Target::new(TransportId::RETICULUM, RADROOTS_RETICULUM_ENDPOINT_URI) - .expect("transport-neutral target"); - assert_eq!( - ensure_reticulum_targets(&[missing_scope]), - Err(RadrootsReticulumError::InvalidEndpoint) + reticulum_error_to_transport_error(RadrootsReticulumError::NonReticulumTarget), + TransportError::InvalidTargetUri ); - assert!(reticulum_target().scope().is_some()); - - for behavior in [ - RadrootsReticulumBehavior::RejectDeliveryAttempts, - RadrootsReticulumBehavior::DeferDeliveryPlans, - ] { - let outcome = reticulum_outcome(behavior); - assert!(outcome.code.is_some()); - assert!(outcome.message.is_some()); - } } } diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs @@ -1,162 +1,113 @@ -use radroots_transport::capability::{Availability, Maturity}; -use radroots_transport::sink::EventSink; -use radroots_transport::source::{EventSource, FetchBounds, FetchRequest}; -use radroots_transport::target::{TargetLabel, TargetScope}; +use futures::executor::block_on; +use radroots_event::{SignedEvent, wire::Nip01EventWire}; use radroots_transport::{ - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportImplementationState, RadrootsTransportPayload, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, Target, TargetSet, - TransportId, + DeliveryRequest, Error as TransportError, EventSink, EventSource, FetchRequest, Target, + TargetSet, TransportId, + capability::{Availability, Maturity}, + outcome::{DeliveryOutcomeKind, FetchTargetState}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::DeliveryPayload, + source::FetchBounds, + target::{TargetLabel, TargetScope}, }; use radroots_transport_reticulum::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsReticulumAgentEndpoint, - RadrootsReticulumBehavior, RadrootsReticulumEndpoint, RadrootsReticulumError, - RadrootsReticulumFetchRequest, RadrootsReticulumProfile, RadrootsReticulumTransport, - ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, - ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RETICULUM_V1_MAX_PAYLOAD_BYTES, + RadrootsReticulumAgentEndpoint, RadrootsReticulumBehavior, RadrootsReticulumEndpoint, + RadrootsReticulumError, RadrootsReticulumProfile, RadrootsReticulumTransport, + ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, + ReticulumFragmentIntegrityV1, ReticulumFragmentPolicyV1, ReticulumFragmentationModeV1, + ReticulumGatewaySemanticsV1, ReticulumPayloadPolicyV1, ReticulumPrivacySemanticsV1, + ReticulumRoutingMetadataV1, }; -#[cfg(feature = "serde")] -use serde_json::Value; -fn reticulum_target(uri: &str) -> Target { - assert_eq!(uri, RADROOTS_RETICULUM_ENDPOINT_URI); - ReticulumDestinationV1::local() - .transport_target() - .expect("reticulum target") +fn signed_event() -> SignedEvent { + let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; + SignedEvent::from_wire_verified_id(Nip01EventWire::parse_json(raw).expect("wire event"), raw) + .expect("signed event") } -fn scoped_reticulum_target(scope: &str) -> Target { - Target::new_with_metadata( - TransportId::RETICULUM, - RADROOTS_RETICULUM_ENDPOINT_URI, - Some(TargetScope::parse(scope).expect("scope")), - None, - ) - .expect("scoped reticulum target") +fn scope() -> TargetScope { + TargetScope::parse(RADROOTS_RETICULUM_SCOPE_ID).expect("Reticulum scope") } -fn nostr_target() -> Target { - Target::new(TransportId::NOSTR, "wss://relay.example").expect("nostr target") +fn target() -> Target { + ReticulumDestinationV1::local() + .transport_target() + .expect("Reticulum target") } -fn delivery_request(targets: Vec<Target>) -> RadrootsTransportDeliveryRequest { - RadrootsTransportDeliveryRequest::new( +fn target_set(target: Target) -> TargetSet { + TargetSet::new(vec![target]).expect("target set") +} + +fn delivery(target: Target) -> DeliveryRequest { + DeliveryRequest::new( "reticulum-delivery", - reticulum_payload(), - TargetSet::new(targets).expect("target set"), - RadrootsTransportSatisfactionPolicy::any_accepted(), + DeliveryPayload::new(signed_event()), + target_set(target), + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + 1_800_000_200_000, ) .expect("delivery request") } -fn reticulum_payload() -> RadrootsTransportPayload { - RadrootsTransportPayload::mesh_frame_cbor("reticulum-message", [1_u8, 2, 3]).expect("payload") +fn fetch(target: Target) -> FetchRequest { + FetchRequest::new( + "reticulum-fetch", + target_set(target), + FetchBounds::new(10, 1_800_000_200_000).expect("fetch bounds"), + ) + .expect("fetch request") } #[test] -fn default_profile_is_configured_deferred_until_implemented_and_rejecting() { - let profile = RadrootsReticulumProfile::default(); - let status = profile.status(); - - assert_eq!(profile.profile_id(), "transport.reticulum.default"); - assert_eq!(profile.endpoint().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!(profile.scope().as_str(), RADROOTS_RETICULUM_SCOPE_ID); - assert_eq!(profile.agent_endpoint(), None); - assert_eq!( - profile.behavior(), - RadrootsReticulumBehavior::RejectDeliveryAttempts - ); - assert_eq!( - status.transport_status.implementation, - RadrootsTransportImplementationState::Real - ); +fn payload_fragment_and_routing_policies_are_explicitly_inert() { + let fragments = ReticulumFragmentPolicyV1::unsupported(); + assert_eq!(fragments.mode, ReticulumFragmentationModeV1::Unsupported); + assert_eq!(fragments.max_fragment_count, 1); assert_eq!( - status.transport_status.maturity, - RadrootsTransportCapabilityMaturity::Preview + fragments.max_reassembled_bytes, + RETICULUM_V1_MAX_PAYLOAD_BYTES ); assert_eq!( - status.transport_status.availability, - RadrootsTransportCapabilityAvailability::Unavailable - ); - assert!(status.transport_status.configured); - assert_eq!( - status.transport_status.profile_id.as_deref(), - Some("transport.reticulum.default") - ); - assert_eq!( - status.transport_status.endpoint_uri.as_deref(), - Some(RADROOTS_RETICULUM_ENDPOINT_URI) - ); - assert_eq!( - profile.destination().uri().as_str(), - RADROOTS_RETICULUM_ENDPOINT_URI + fragments.duplicate_fragment_behavior, + ReticulumDuplicateFragmentBehaviorV1::Reject ); assert_eq!( - profile.destination().routing().scope.as_str(), - RADROOTS_RETICULUM_SCOPE_ID + fragments.integrity_verification, + ReticulumFragmentIntegrityV1::PayloadDigest ); + + let payload = ReticulumPayloadPolicyV1::v1(); + assert_eq!(payload.max_payload_bytes, RETICULUM_V1_MAX_PAYLOAD_BYTES); + assert_eq!(payload.fragment_policy, fragments); + + let routing = ReticulumRoutingMetadataV1::local(); + assert_eq!(routing.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID); assert_eq!( - status.destination.routing().gateway, + routing.gateway, ReticulumGatewaySemanticsV1::NoGatewayForwarding ); assert_eq!( - status.destination.routing().privacy, + routing.privacy, ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly ); - assert!(status.capability_report.delivery_required); - assert!(!status.capability_report.fetch_required); - assert!(!status.capability_report.can_deliver); - assert!(!status.capability_report.can_fetch); - assert!(!status.capability_report.can_discover); - assert!(!status.capability_report.can_forward_gateway); - assert!(!status.capability_report.can_observe_receipts); - assert_eq!( - status.capability_report.destination.fingerprint(), - status.destination.fingerprint() - ); - assert_eq!( - status.capability_report.payload_policy.fragment_policy.mode, - ReticulumFragmentationModeV1::Unsupported - ); - assert_eq!( - status - .capability_report - .payload_policy - .fragment_policy - .max_fragment_count, - 1 - ); - assert_eq!( - status - .capability_report - .payload_policy - .fragment_policy - .duplicate_fragment_behavior, - ReticulumDuplicateFragmentBehaviorV1::Reject - ); - assert_eq!( - status - .capability_report - .payload_policy - .fragment_policy - .integrity_verification, - ReticulumFragmentIntegrityV1::PayloadDigest - ); - assert_eq!( - status.transport_status.message, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE - ); - assert!(!status.transport_status.usable_for_delivery); - assert_eq!(status.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID); - assert_eq!(status.agent_endpoint, None); + + let report = ReticulumCapabilityReportV1::unavailable_local(); + assert!(report.delivery_required); + assert!(!report.fetch_required); + assert!(!report.can_deliver); + assert!(!report.can_fetch); + assert!(!report.can_discover); + assert!(!report.can_forward_gateway); + assert!(!report.can_observe_receipts); + assert_eq!(report.payload_policy, payload); } #[test] -fn endpoint_and_profile_validation_are_strict_and_canonical() { - let endpoint = - RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI).expect("endpoint"); +fn endpoint_and_agent_endpoint_values_are_strict_and_canonical() { + let endpoint = RadrootsReticulumEndpoint::default(); assert_eq!(endpoint.as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); assert_eq!(endpoint.to_string(), RADROOTS_RETICULUM_ENDPOINT_URI); assert_eq!( @@ -164,526 +115,250 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { RADROOTS_RETICULUM_ENDPOINT_URI ); assert_eq!( - RadrootsReticulumEndpoint::default().as_str(), - RADROOTS_RETICULUM_ENDPOINT_URI + RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI).unwrap(), + endpoint ); + for invalid in ["", " ", "RETICULUM:local", "reticulum:remote"] { + assert_eq!( + RadrootsReticulumEndpoint::parse(invalid).unwrap_err(), + RadrootsReticulumError::InvalidEndpoint + ); + } + for valid in [ + "reticulum-agent:local-controller", + "reticulum-agent://localhost:19999", + ] { + let agent = RadrootsReticulumAgentEndpoint::parse(valid).expect("agent endpoint"); + assert_eq!(agent.as_str(), valid); + assert_eq!(agent.to_string(), valid); + assert_eq!(agent.into_string(), valid); + } + for invalid in [ + "", + " reticulum-agent:local", + "reticulum-agent:local ", + "reticulum-agent:local\n", + "reticulum agent:local", + "RETICULUM-AGENT:local", + "reticulum-agent:", + ] { + assert_eq!( + RadrootsReticulumAgentEndpoint::parse(invalid).unwrap_err(), + RadrootsReticulumError::InvalidAgentEndpoint, + "{invalid:?}" + ); + } +} + +#[test] +fn destinations_preserve_transport_identity_scope_and_optional_labels() { + let local = ReticulumDestinationV1::local(); + assert_eq!(local.uri().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); + assert_eq!(local.routing().scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID); + assert_eq!(local.label(), None); + + let label = TargetLabel::parse("Local node").expect("label"); + let labeled = ReticulumDestinationV1::new( + RADROOTS_RETICULUM_ENDPOINT_URI, + TargetScope::parse("farm.mesh").expect("scope"), + Some(label.clone()), + ) + .expect("destination"); + assert_eq!(labeled.label(), Some(&label)); + assert_ne!(labeled.fingerprint(), local.fingerprint()); + + let transport_target = labeled.transport_target().expect("transport target"); + assert_eq!(transport_target.kind(), &TransportId::RETICULUM); assert_eq!( - RadrootsReticulumEndpoint::parse(" ").expect_err("empty endpoint"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse("reticulum:").expect_err("empty endpoint"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse("https://target").expect_err("wrong scheme"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse("RETICULUM:unavailable").expect_err("case drift endpoint"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse(" reticulum:unavailable") - .expect_err("leading whitespace endpoint"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse("reticulum:unavailable ") - .expect_err("trailing whitespace endpoint"), - RadrootsReticulumError::InvalidEndpoint - ); - assert_eq!( - RadrootsReticulumEndpoint::parse("reticulum:custom").expect_err("custom endpoint"), - RadrootsReticulumError::InvalidEndpoint + ReticulumDestinationV1::from_target(&transport_target).expect("round trip"), + labeled ); + + let local_target = Target::local("local:memory").expect("local target"); assert_eq!( - RadrootsReticulumEndpoint::parse("reticulum:bad target").expect_err("whitespace endpoint"), - RadrootsReticulumError::InvalidEndpoint + ReticulumDestinationV1::from_target(&local_target).unwrap_err(), + TransportError::InvalidTargetUri ); + let wrong_uri = Target::new_with_metadata( + TransportId::RETICULUM, + "reticulum:remote", + Some(scope()), + None, + ) + .expect("wrong URI target"); assert_eq!( - RadrootsReticulumEndpoint::parse("reticulum:bad\ntarget").expect_err("control endpoint"), - RadrootsReticulumError::InvalidEndpoint + ReticulumDestinationV1::from_target(&wrong_uri).unwrap_err(), + TransportError::InvalidTargetUri ); - let agent_endpoint = RadrootsReticulumAgentEndpoint::parse("reticulum-agent://localhost:19999") - .expect("agent endpoint"); - assert_eq!(agent_endpoint.as_str(), "reticulum-agent://localhost:19999"); + let missing_scope = + Target::new(TransportId::RETICULUM, RADROOTS_RETICULUM_ENDPOINT_URI).expect("target"); assert_eq!( - agent_endpoint.to_string(), - "reticulum-agent://localhost:19999" + ReticulumDestinationV1::from_target(&missing_scope).unwrap_err(), + TransportError::EmptyTargetScope ); - assert_eq!( - agent_endpoint.clone().into_string(), - "reticulum-agent://localhost:19999" +} + +#[test] +fn profiles_expose_canonical_preview_configuration() { + let default = RadrootsReticulumProfile::default(); + assert_eq!(default.profile_id(), "transport.reticulum.default"); + assert_eq!(default.endpoint().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); + assert_eq!(default.scope().as_str(), RADROOTS_RETICULUM_SCOPE_ID); + assert_eq!(default.agent_endpoint(), None); + assert_eq!( + default.behavior(), + RadrootsReticulumBehavior::RejectDeliveryAttempts ); - let local_agent_endpoint = - RadrootsReticulumAgentEndpoint::parse("reticulum-agent:local-controller") - .expect("local agent endpoint"); assert_eq!( - local_agent_endpoint.as_str(), - "reticulum-agent:local-controller" + default.destination(), + &default.capability_report().destination ); - for invalid_agent in [ - "", - " reticulum-agent://localhost", - "reticulum-agent:", - "reticulum agent", - "agent", - "https://localhost:19999", - "ws://localhost:19999", - "reticulum://localhost:19999", - "RETICULUM-AGENT://localhost:19999", - ] { - assert_eq!( - RadrootsReticulumAgentEndpoint::parse(invalid_agent) - .expect_err("invalid agent endpoint"), - RadrootsReticulumError::InvalidAgentEndpoint - ); - } + assert_eq!( RadrootsReticulumProfile::new( - "transport reticulum", - endpoint, - TargetScope::parse(RADROOTS_RETICULUM_SCOPE_ID).expect("Reticulum scope"), + "", + RadrootsReticulumEndpoint::default(), + scope(), None, RadrootsReticulumBehavior::RejectDeliveryAttempts, ) - .expect_err("profile id whitespace"), + .unwrap_err(), RadrootsReticulumError::InvalidProfileId ); assert_eq!( RadrootsReticulumProfile::new( - "", + "transport reticulum", RadrootsReticulumEndpoint::default(), - TargetScope::parse(RADROOTS_RETICULUM_SCOPE_ID).expect("Reticulum scope"), + scope(), None, RadrootsReticulumBehavior::RejectDeliveryAttempts, ) - .expect_err("empty profile id"), + .unwrap_err(), RadrootsReticulumError::InvalidProfileId ); - let profile = RadrootsReticulumProfile::new( + + let agent = + RadrootsReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint"); + let custom = RadrootsReticulumProfile::new( "transport.reticulum.custom", RadrootsReticulumEndpoint::default(), - TargetScope::parse(RADROOTS_RETICULUM_SCOPE_ID).expect("Reticulum scope"), - Some(agent_endpoint), + TargetScope::parse("farm.mesh").expect("scope"), + Some(agent.clone()), RadrootsReticulumBehavior::DeferDeliveryPlans, ) - .expect("custom behavior profile"); - assert_eq!(profile.profile_id(), "transport.reticulum.custom"); - assert_eq!(profile.endpoint().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); + .expect("profile"); + assert_eq!(custom.profile_id(), "transport.reticulum.custom"); + assert_eq!(custom.agent_endpoint(), Some(&agent)); assert_eq!( - profile.behavior(), + custom.behavior(), RadrootsReticulumBehavior::DeferDeliveryPlans ); + + let updated = default + .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans) + .with_agent_endpoint(agent.clone()); + assert_eq!(updated.agent_endpoint(), Some(&agent)); assert_eq!( - profile.agent_endpoint().map(|endpoint| endpoint.as_str()), - Some("reticulum-agent://localhost:19999") + updated.behavior(), + RadrootsReticulumBehavior::DeferDeliveryPlans ); assert_eq!( - profile - .capability_report() - .destination - .routing() - .scope - .as_str(), - RADROOTS_RETICULUM_SCOPE_ID + RadrootsReticulumBehavior::default(), + RadrootsReticulumBehavior::RejectDeliveryAttempts ); } #[test] -fn direct_reticulum_delivery_accepts_any_typed_scope_as_inert_metadata() { +fn canonical_source_and_sink_return_request_bound_unavailable_evidence() { let transport = RadrootsReticulumTransport::default(); - let request = delivery_request(vec![scoped_reticulum_target("farm-north.mesh_1")]); - let receipt = transport.deliver(request).expect("delivery receipt"); - - assert_eq!(receipt.target_receipts().len(), 1); - assert_eq!( - receipt.target_receipts()[0] - .target - .scope() - .map(|scope| scope.as_str()), - Some("farm-north.mesh_1") - ); - assert_eq!( - receipt.target_receipts()[0].status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - assert_eq!( - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), - 0 - ); - - let deferred_transport = RadrootsReticulumTransport::new( - RadrootsReticulumProfile::default() - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans), - ); - let deferred = deferred_transport - .deliver(delivery_request(vec![scoped_reticulum_target( - "farm-south.mesh_2", - )])) - .expect("deferred delivery receipt"); assert_eq!( - deferred.target_receipts()[0] - .target - .scope() - .map(|scope| scope.as_str()), - Some("farm-south.mesh_2") - ); - assert_eq!( - deferred.target_receipts()[0].status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - assert_eq!( - deferred.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), - 0 + transport.profile().profile_id(), + "transport.reticulum.default" ); -} + let transport = RadrootsReticulumTransport::new(transport.profile().clone()); -#[test] -fn final_transport_spis_report_unavailable_reticulum_source_and_sink() { - let transport = RadrootsReticulumTransport::default(); - let target_set = TargetSet::new(vec![reticulum_target(RADROOTS_RETICULUM_ENDPOINT_URI)]) - .expect("target set"); - let sink_status = - futures::executor::block_on(EventSink::status(&transport)).expect("sink status"); + let sink_status = block_on(EventSink::status(&transport)).expect("sink status"); assert_eq!(sink_status.transport_id(), TransportId::RETICULUM); assert_eq!(sink_status.maturity(), Maturity::Preview); assert_eq!(sink_status.availability(), Availability::Unavailable); assert!(!sink_status.capabilities().can_deliver()); + assert_eq!( + sink_status.message(), + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE + ); - let source_status = - futures::executor::block_on(EventSource::status(&transport)).expect("source status"); + let source_status = block_on(EventSource::status(&transport)).expect("source status"); assert_eq!(source_status.transport_id(), TransportId::RETICULUM); assert_eq!(source_status.maturity(), Maturity::Preview); assert_eq!(source_status.availability(), Availability::Unavailable); assert!(!source_status.capabilities().can_fetch()); - - let fetch = futures::executor::block_on(EventSource::fetch( - &transport, - FetchRequest::new( - "core-fetch", - target_set, - FetchBounds::new(10, 10_000).expect("fetch bounds"), - ) - .expect("fetch request"), - )) - .expect("fetch page"); - assert!(fetch.events().is_empty()); - assert_eq!(fetch.target_outcomes().len(), 1); -} - -#[test] -fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing() { - let transport = RadrootsReticulumTransport::default(); - let request = delivery_request(vec![reticulum_target(RADROOTS_RETICULUM_ENDPOINT_URI)]); - let receipt = transport.deliver(request).expect("delivery receipt"); - - assert_eq!(receipt.target_receipts().len(), 1); assert_eq!( - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), - 0 - ); - for target_receipt in receipt.target_receipts() { - assert_eq!(target_receipt.target.kind(), &TransportId::RETICULUM); - assert_eq!( - target_receipt.status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - assert_eq!( - target_receipt.outcome.code.as_deref(), - Some("transport_unavailable") - ); - assert_eq!( - target_receipt.outcome.message.as_deref(), - Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) - ); - } -} - -#[test] -fn noncanonical_reticulum_targets_are_rejected() { - for invalid in [ - " reticulum:unavailable", - "reticulum:unavailable ", - "RETICULUM:unavailable", - "reticulum:Unavailable", - "reticulum:temporary", - "reticulum:unavailable-alt", - "reticulum:custom", - ] { - let result = Target::new(TransportId::RETICULUM, invalid) - .and_then(|target| ReticulumDestinationV1::from_target(&target).map(|_| target)); - assert_eq!( - result.expect_err("noncanonical Reticulum target"), - radroots_transport::RadrootsTransportError::InvalidTargetUri - ); - } -} - -#[test] -fn deferred_delivery_plan_mode_never_counts_as_satisfied() { - let transport = RadrootsReticulumTransport::new( - RadrootsReticulumProfile::default() - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans), + source_status.message(), + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE ); - let request = delivery_request(vec![reticulum_target(RADROOTS_RETICULUM_ENDPOINT_URI)]); - let receipt = transport.deliver(request).expect("delivery receipt"); + let delivery = delivery(target()); + let receipt = + block_on(EventSink::deliver(&transport, delivery.clone())).expect("delivery receipt"); + receipt + .validate_for_request(&delivery) + .expect("request bound"); assert_eq!(receipt.target_receipts().len(), 1); + assert!(!receipt.target_receipts()[0].was_attempted()); assert_eq!( - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), - 0 - ); - assert_eq!( - receipt.target_receipts()[0].status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + receipt.target_receipts()[0].outcome().kind(), + DeliveryOutcomeKind::Unavailable ); assert_eq!( - receipt.target_receipts()[0].outcome.code.as_deref(), - Some("deferred_until_implemented") - ); - assert!( - !RadrootsTransportSatisfactionPolicy::any_accepted() - .is_satisfied_by( - 1, - receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted) - ) - .expect("satisfaction check") + receipt.target_receipts()[0].outcome().code(), + Some("transport_unavailable") ); -} - -#[test] -fn non_reticulum_targets_are_rejected_without_nostr_routing() { - let transport = RadrootsReticulumTransport::default(); - let err = transport - .deliver(delivery_request(vec![nostr_target()])) - .expect_err("non-reticulum target"); - - assert_eq!(err, RadrootsReticulumError::NonReticulumTarget); -} -#[test] -fn reticulum_target_constructor_always_supplies_typed_scope() { - let target = reticulum_target(RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!( - target.scope().map(|scope| scope.as_str()), - Some(RADROOTS_RETICULUM_SCOPE_ID) - ); -} - -#[test] -fn fetch_reports_deferred_until_implemented_without_observed_events() { - let transport = RadrootsReticulumTransport::default(); - assert_eq!( - transport.profile().profile_id(), - "transport.reticulum.default" - ); - assert_eq!( - transport.status().transport_status.implementation, - RadrootsTransportImplementationState::Real - ); - let receipt = transport - .fetch(RadrootsReticulumFetchRequest::new("fetch-1", 10).expect("fetch request")) - .expect("fetch receipt"); - - assert_eq!(receipt.request_id, "fetch-1"); - assert_eq!(receipt.endpoint_uri, RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!(receipt.observed_event_count, 0); - assert_eq!( - receipt.implementation, - RadrootsTransportImplementationState::Real - ); - assert_eq!(receipt.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID); - assert_eq!(receipt.agent_endpoint, None); - assert_eq!( - receipt.outcome.status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - assert_eq!( - RadrootsReticulumFetchRequest::new("fetch-0", 0).expect_err("zero limit"), - RadrootsReticulumError::InvalidFetchLimit - ); - assert_eq!( - transport - .fetch(RadrootsReticulumFetchRequest { - request_id: "fetch-public-zero".to_owned(), - max_events: 0, - }) - .expect_err("zero limit at transport boundary"), - RadrootsReticulumError::InvalidFetchLimit - ); - let deferred_transport = RadrootsReticulumTransport::new( - RadrootsReticulumProfile::default() - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans), - ); - let deferred = deferred_transport - .fetch(RadrootsReticulumFetchRequest::new("fetch-deferred", 1).expect("fetch")) - .expect("fetch receipt"); - assert_eq!( - deferred.outcome.status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); -} - -#[test] -fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() { - let agent_endpoint = RadrootsReticulumAgentEndpoint::parse("reticulum-agent://localhost:19999") - .expect("agent endpoint"); - let transport = RadrootsReticulumTransport::new( - RadrootsReticulumProfile::default().with_agent_endpoint(agent_endpoint), - ); - let status = transport.status(); - assert_eq!( - status - .agent_endpoint - .as_ref() - .map(|endpoint| endpoint.as_str()), - Some("reticulum-agent://localhost:19999") - ); - assert_eq!( - status.transport_status.implementation, - RadrootsTransportImplementationState::Real - ); - assert!(!status.transport_status.usable_for_delivery); - assert!(!status.transport_status.capabilities.deliver); - assert!(!status.transport_status.capabilities.fetch); - assert!(!status.transport_status.capabilities.discovery); - assert!(!status.transport_status.capabilities.gateway_forwarding); - assert!(!status.transport_status.capabilities.receipt_observation); - - let receipt = transport - .deliver(delivery_request(vec![reticulum_target( - RADROOTS_RETICULUM_ENDPOINT_URI, - )])) - .expect("delivery receipt"); - assert_eq!( - receipt.target_receipts()[0].status, - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - let fetch = transport - .fetch(RadrootsReticulumFetchRequest::new("fetch-agent", 1).expect("fetch")) - .expect("fetch receipt"); + let fetch = fetch(target()); + let page = block_on(EventSource::fetch(&transport, fetch.clone())).expect("fetch page"); + page.validate_for_request(&fetch).expect("request bound"); + assert!(page.events().is_empty()); + assert_eq!(page.target_outcomes().len(), 1); assert_eq!( - fetch - .agent_endpoint - .as_ref() - .map(|endpoint| endpoint.as_str()), - Some("reticulum-agent://localhost:19999") + page.target_outcomes()[0].state(), + FetchTargetState::Unavailable ); - assert_eq!(fetch.observed_event_count, 0); assert_eq!( - fetch.implementation, - RadrootsTransportImplementationState::Real + page.target_outcomes()[0].message(), + Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) ); } #[test] -#[cfg(feature = "serde")] -fn public_models_round_trip_through_serde() { - let profile = RadrootsReticulumProfile::default() - .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans); - let json = serde_json::to_string(&profile).expect("profile json"); - let decoded: RadrootsReticulumProfile = serde_json::from_str(&json).expect("profile decode"); - - assert_eq!(decoded, profile); -} - -#[test] -#[cfg(feature = "serde")] -fn destination_deserialization_revalidates_canonical_identity() { - let destination = ReticulumDestinationV1::local(); - let canonical = serde_json::to_value(&destination).expect("serialize destination"); - assert_eq!( - serde_json::from_value::<ReticulumDestinationV1>(canonical.clone()) - .expect("deserialize canonical destination"), - destination - ); - - let mut forged_fingerprint = canonical.clone(); - forged_fingerprint - .as_object_mut() - .expect("destination object") - .insert("fingerprint".to_owned(), Value::String("0".repeat(64))); - assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_fingerprint).is_err()); - - let mut forged_scope = canonical.clone(); - forged_scope - .get_mut("routing") - .and_then(Value::as_object_mut) - .expect("routing object") - .insert("scope".to_owned(), Value::String("remote".to_owned())); - assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_scope).is_err()); - - let mut nested_unknown = canonical.clone(); - nested_unknown - .get_mut("routing") - .and_then(Value::as_object_mut) - .expect("routing object") - .insert("unexpected".to_owned(), Value::Bool(true)); - assert!(serde_json::from_value::<ReticulumDestinationV1>(nested_unknown).is_err()); - - let mut top_level_unknown = canonical; - top_level_unknown - .as_object_mut() - .expect("destination object") - .insert("unexpected".to_owned(), Value::Bool(true)); - assert!(serde_json::from_value::<ReticulumDestinationV1>(top_level_unknown).is_err()); -} - -#[test] -fn destination_rejects_non_reticulum_targets() { - let local = Target::new(TransportId::LOCAL, "local:memory").expect("local target"); - assert!(ReticulumDestinationV1::from_target(&local).is_err()); - - let missing_scope = Target::new(TransportId::RETICULUM, RADROOTS_RETICULUM_ENDPOINT_URI) - .expect("unscoped target"); - assert_eq!( - ReticulumDestinationV1::from_target(&missing_scope), - Err(radroots_transport::RadrootsTransportError::EmptyTargetScope) - ); - - let label = TargetLabel::parse("Local node").unwrap(); - let destination = ReticulumDestinationV1::new( - RADROOTS_RETICULUM_ENDPOINT_URI, - TargetScope::parse(RADROOTS_RETICULUM_SCOPE_ID).unwrap(), - Some(label.clone()), - ) - .unwrap(); - assert_eq!(destination.label(), Some(&label)); - let target = destination.transport_target().unwrap(); - assert_eq!( - ReticulumDestinationV1::from_target(&target).unwrap(), - destination - ); -} +fn source_and_sink_reject_noncanonical_targets_before_adapter_effects() { + let transport = RadrootsReticulumTransport::default(); + let invalid_targets = [ + Target::nostr_relay("wss://relay.example").expect("Nostr target"), + Target::new_with_metadata( + TransportId::RETICULUM, + "reticulum:remote", + Some(scope()), + None, + ) + .expect("wrong URI target"), + Target::new(TransportId::RETICULUM, RADROOTS_RETICULUM_ENDPOINT_URI) + .expect("unscoped target"), + ]; -#[test] -fn reticulum_source_remains_inert_without_runtime_delivery_hooks() { - let source = include_str!("../src/lib.rs").to_ascii_lowercase(); - for forbidden in [ - "socket", - "rnsd", - "python", - "identity", - "send_mesh", - "fallback", - "nostr", - ] { - assert!( - !source.contains(forbidden), - "Reticulum source contains forbidden runtime hook {forbidden}" + for invalid in invalid_targets { + let failure = block_on(EventSink::deliver(&transport, delivery(invalid.clone()))) + .expect_err("sink must reject target"); + assert_eq!(failure.code(), "invalid_transport_contract"); + assert_eq!( + block_on(EventSource::fetch(&transport, fetch(invalid))).unwrap_err(), + TransportError::InvalidTargetUri ); } } #[test] -fn reticulum_errors_and_defaults_are_stable() { - assert_eq!( - RadrootsReticulumBehavior::default(), - RadrootsReticulumBehavior::RejectDeliveryAttempts - ); +fn reticulum_error_messages_are_stable() { let cases = [ ( RadrootsReticulumError::InvalidEndpoint, @@ -698,16 +373,53 @@ fn reticulum_errors_and_defaults_are_stable() { "invalid Reticulum profile id", ), ( - RadrootsReticulumError::InvalidFetchLimit, - "Reticulum fetch limit must be greater than zero", - ), - ( RadrootsReticulumError::NonReticulumTarget, "Reticulum transport received a non-Reticulum target", ), ]; + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } +} + +#[test] +#[cfg(feature = "serde")] +fn public_models_round_trip_and_destination_identity_is_revalidated() { + let profile = RadrootsReticulumProfile::default() + .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans); + let encoded = serde_json::to_string(&profile).expect("profile JSON"); + assert_eq!( + serde_json::from_str::<RadrootsReticulumProfile>(&encoded).expect("profile round trip"), + profile + ); + + let destination = ReticulumDestinationV1::new( + RADROOTS_RETICULUM_ENDPOINT_URI, + scope(), + Some(TargetLabel::parse("Local node").expect("label")), + ) + .expect("destination"); + let value = serde_json::to_value(&destination).expect("destination JSON"); + assert_eq!( + serde_json::from_value::<ReticulumDestinationV1>(value.clone()) + .expect("destination round trip"), + destination + ); - for (error, message) in cases { - assert_eq!(error.to_string(), message); + for (path, forged) in [ + ("uri", serde_json::json!("reticulum:remote")), + ("label", serde_json::json!(" Other node ")), + ("fingerprint", serde_json::json!("0".repeat(64))), + ] { + let mut invalid = value.clone(); + invalid[path] = forged; + assert!( + serde_json::from_value::<ReticulumDestinationV1>(invalid).is_err(), + "forged {path} must fail" + ); } + + let mut routing = value; + routing["routing"]["scope"] = serde_json::json!("remote"); + assert!(serde_json::from_value::<ReticulumDestinationV1>(routing).is_err()); } diff --git a/docs/api/radroots_event.txt b/docs/api/radroots_event.txt @@ -440,6 +440,58 @@ pub fn radroots_event::calendar::canonical_calendar_geohash_is_valid(&str) -> bo pub fn radroots_event::calendar::canonical_calendar_tag_text_is_valid(&str) -> bool pub fn radroots_event::calendar::covered_utc_days(u64, core::option::Option<u64>) -> core::result::Result<core::ops::range::RangeInclusive<u64>, radroots_event::calendar::CalendarEventError> pub mod radroots_event::contract +pub enum radroots_event::contract::ContractIdentityError +pub radroots_event::contract::ContractIdentityError::ContractIdMissing +pub radroots_event::contract::ContractIdentityError::ContractIdNamespace +pub radroots_event::contract::ContractIdentityError::ContractIdSyntax +pub radroots_event::contract::ContractIdentityError::ContractIdTooLong +pub radroots_event::contract::ContractIdentityError::ContractIdTooLong::actual: usize +pub radroots_event::contract::ContractIdentityError::ContractIdTooLong::max: usize +pub radroots_event::contract::ContractIdentityError::RegistryVersionZero +pub radroots_event::contract::ContractIdentityError::UnknownContract +pub radroots_event::contract::ContractIdentityError::UnknownContract::contract_id: alloc::string::String +pub radroots_event::contract::ContractIdentityError::UnsupportedRegistryVersion +pub radroots_event::contract::ContractIdentityError::UnsupportedRegistryVersion::actual: u32 +impl radroots_event::contract::ContractIdentityError +pub const fn radroots_event::contract::ContractIdentityError::code(&self) -> &'static str +impl core::error::Error for radroots_event::contract::ContractIdentityError +impl core::fmt::Display for radroots_event::contract::ContractIdentityError +pub fn radroots_event::contract::ContractIdentityError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_event::contract::ContractId(_) +impl radroots_event::contract::ContractId +pub fn radroots_event::contract::ContractId::as_str(&self) -> &str +pub fn radroots_event::contract::ContractId::into_string(self) -> alloc::string::String +pub fn radroots_event::contract::ContractId::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_event::contract::ContractIdentityError> +impl core::fmt::Display for radroots_event::contract::ContractId +pub fn radroots_event::contract::ContractId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl serde_core::ser::Serialize for radroots_event::contract::ContractId +pub fn radroots_event::contract::ContractId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_event::contract::ContractId +pub fn radroots_event::contract::ContractId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_event::contract::ContractKey +impl radroots_event::contract::ContractKey +pub fn radroots_event::contract::ContractKey::contract(&self) -> &'static EventContract +pub const fn radroots_event::contract::ContractKey::contract_id(&self) -> &radroots_event::contract::ContractId +pub fn radroots_event::contract::ContractKey::current(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_event::contract::ContractIdentityError> +pub fn radroots_event::contract::ContractKey::new(radroots_event::contract::RegistryVersion, radroots_event::contract::ContractId) -> core::result::Result<Self, radroots_event::contract::ContractIdentityError> +pub const fn radroots_event::contract::ContractKey::registry_version(&self) -> radroots_event::contract::RegistryVersion +impl serde_core::ser::Serialize for radroots_event::contract::ContractKey +pub fn radroots_event::contract::ContractKey::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_event::contract::ContractKey +pub fn radroots_event::contract::ContractKey::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_event::contract::RegistryVersion(_) +impl radroots_event::contract::RegistryVersion +pub const radroots_event::contract::RegistryVersion::CURRENT: Self +pub const radroots_event::contract::RegistryVersion::V7: Self +pub const fn radroots_event::contract::RegistryVersion::get(self) -> u32 +pub const fn radroots_event::contract::RegistryVersion::new(u32) -> core::result::Result<Self, radroots_event::contract::ContractIdentityError> +impl core::fmt::Display for radroots_event::contract::RegistryVersion +pub fn radroots_event::contract::RegistryVersion::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl serde_core::ser::Serialize for radroots_event::contract::RegistryVersion +pub fn radroots_event::contract::RegistryVersion::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_event::contract::RegistryVersion +pub fn radroots_event::contract::RegistryVersion::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub const radroots_event::contract::CONTRACT_ID_MAX_BYTES: usize pub const radroots_event::contract::VERSION: &str pub mod radroots_event::draft pub enum radroots_event::draft::DraftError @@ -462,27 +514,6 @@ pub radroots_event::draft::DraftError::DraftExpectedEventIdMismatch::expected_ev pub radroots_event::draft::DraftError::Envelope(radroots_event::envelope::EventEnvelopeError) pub radroots_event::draft::DraftError::IdParse(radroots_event::id::ParseError) pub radroots_event::draft::DraftError::SignedEvent(radroots_event::draft::SignedEventError) -pub radroots_event::draft::DraftError::SignedEventComputedIdMismatch -pub radroots_event::draft::DraftError::SignedEventComputedIdMismatch::computed_event_id: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventComputedIdMismatch::expected_event_id: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventContentMismatch -pub radroots_event::draft::DraftError::SignedEventContentMismatch::actual_len: usize -pub radroots_event::draft::DraftError::SignedEventContentMismatch::expected_len: usize -pub radroots_event::draft::DraftError::SignedEventCreatedAtMismatch -pub radroots_event::draft::DraftError::SignedEventCreatedAtMismatch::actual_created_at: u64 -pub radroots_event::draft::DraftError::SignedEventCreatedAtMismatch::expected_created_at: u64 -pub radroots_event::draft::DraftError::SignedEventIdMismatch -pub radroots_event::draft::DraftError::SignedEventIdMismatch::actual_event_id: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventIdMismatch::expected_event_id: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventKindMismatch -pub radroots_event::draft::DraftError::SignedEventKindMismatch::actual_kind: u32 -pub radroots_event::draft::DraftError::SignedEventKindMismatch::expected_kind: u32 -pub radroots_event::draft::DraftError::SignedEventPubkeyMismatch -pub radroots_event::draft::DraftError::SignedEventPubkeyMismatch::actual_pubkey: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventPubkeyMismatch::expected_pubkey: alloc::string::String -pub radroots_event::draft::DraftError::SignedEventTagsMismatch -pub radroots_event::draft::DraftError::SignedEventTagsMismatch::actual_len: usize -pub radroots_event::draft::DraftError::SignedEventTagsMismatch::expected_len: usize pub radroots_event::draft::DraftError::UnknownContract(alloc::string::String) impl core::convert::From<radroots_event::draft::SignedEventError> for radroots_event::draft::DraftError pub fn radroots_event::draft::DraftError::from(radroots_event::draft::SignedEventError) -> Self @@ -505,25 +536,6 @@ pub fn radroots_event::draft::SignedEventError::from(radroots_event::envelope::E impl core::error::Error for radroots_event::draft::SignedEventError impl core::fmt::Display for radroots_event::draft::SignedEventError pub fn radroots_event::draft::SignedEventError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -pub struct radroots_event::draft::EventDraft -impl radroots_event::draft::EventDraft -pub fn radroots_event::draft::EventDraft::content(&self) -> &str -pub fn radroots_event::draft::EventDraft::contract_id(&self) -> &str -pub fn radroots_event::draft::EventDraft::contract_registry_version(&self) -> u32 -pub fn radroots_event::draft::EventDraft::created_at(&self) -> radroots_event::envelope::EventTimestamp -pub fn radroots_event::draft::EventDraft::created_at_u64(&self) -> u64 -pub fn radroots_event::draft::EventDraft::expected_event_id(&self) -> &radroots_event::id::EventId -pub fn radroots_event::draft::EventDraft::expected_event_id_hex(&self) -> alloc::string::String -pub fn radroots_event::draft::EventDraft::expected_pubkey(&self) -> &radroots_identity::key::PublicKey -pub fn radroots_event::draft::EventDraft::kind(&self) -> radroots_event::envelope::EventKind -pub fn radroots_event::draft::EventDraft::kind_u32(&self) -> u32 -pub fn radroots_event::draft::EventDraft::new(impl core::convert::Into<alloc::string::String>, u32, u64, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, impl core::convert::Into<alloc::string::String>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event::draft::DraftError> -pub fn radroots_event::draft::EventDraft::nip01_preimage(&self) -> core::result::Result<alloc::string::String, radroots_event::draft::DraftError> -pub fn radroots_event::draft::EventDraft::tags(&self) -> &radroots_event::envelope::EventTags -pub fn radroots_event::draft::EventDraft::tags_as_vec(&self) -> alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> -pub fn radroots_event::draft::EventDraft::validate_for_signing(&self) -> core::result::Result<(), radroots_event::draft::DraftError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_event::draft::EventDraft -pub fn radroots_event::draft::EventDraft::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_event::draft::SignedEvent impl radroots_event::draft::SignedEvent pub fn radroots_event::draft::SignedEvent::content(&self) -> &str @@ -557,7 +569,6 @@ pub radroots_event::draft::SignedEventParts::sig: alloc::string::String pub radroots_event::draft::SignedEventParts::tags: alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> pub fn radroots_event::draft::compute_nip01_event_id(&str, u64, u32, &[alloc::vec::Vec<alloc::string::String>], &str) -> core::result::Result<radroots_event::id::EventId, radroots_event::draft::DraftError> pub fn radroots_event::draft::nip01_event_id_preimage(&str, u64, u32, &[alloc::vec::Vec<alloc::string::String>], &str) -> core::result::Result<alloc::string::String, radroots_event::draft::DraftError> -pub fn radroots_event::draft::validate_signed_nostr_event_matches_draft(&radroots_event::draft::SignedEvent, &radroots_event::draft::EventDraft) -> core::result::Result<(), radroots_event::draft::DraftError> pub mod radroots_event::envelope pub mod radroots_event::envelope::event_head pub mod radroots_event::envelope::kind @@ -1306,6 +1317,26 @@ pub const radroots_event::food::availability::RADROOTS_FOOD_IMAGE_MAX_COUNT: usi pub const radroots_event::food::availability::RADROOTS_FOOD_TEXT_MAX_BYTES: usize pub fn radroots_event::food::availability::food_media_blossom_digest(&str) -> core::option::Option<radroots_blossom::hash::Sha256> pub fn radroots_event::food::availability::food_media_http_url_is_valid(&str) -> bool +pub mod radroots_event::generic_draft +pub struct radroots_event::generic_draft::GenericEventDraft +impl radroots_event::generic_draft::GenericEventDraft +pub fn radroots_event::generic_draft::GenericEventDraft::content(&self) -> &str +pub const fn radroots_event::generic_draft::GenericEventDraft::contract(&self) -> &radroots_event::contract::ContractKey +pub fn radroots_event::generic_draft::GenericEventDraft::contract_id(&self) -> &str +pub const fn radroots_event::generic_draft::GenericEventDraft::created_at(&self) -> radroots_event::envelope::EventTimestamp +pub const fn radroots_event::generic_draft::GenericEventDraft::created_at_u64(&self) -> u64 +pub const fn radroots_event::generic_draft::GenericEventDraft::expected_event_id(&self) -> &radroots_event::id::EventId +pub fn radroots_event::generic_draft::GenericEventDraft::expected_event_id_hex(&self) -> alloc::string::String +pub const fn radroots_event::generic_draft::GenericEventDraft::expected_pubkey(&self) -> &radroots_identity::key::PublicKey +pub const fn radroots_event::generic_draft::GenericEventDraft::kind(&self) -> radroots_event::envelope::EventKind +pub const fn radroots_event::generic_draft::GenericEventDraft::kind_u32(&self) -> u32 +pub fn radroots_event::generic_draft::GenericEventDraft::new(impl core::convert::Into<alloc::string::String>, u32, u64, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, impl core::convert::Into<alloc::string::String>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event::draft::DraftError> +pub fn radroots_event::generic_draft::GenericEventDraft::nip01_preimage(&self) -> core::result::Result<alloc::string::String, radroots_event::draft::DraftError> +pub const fn radroots_event::generic_draft::GenericEventDraft::tags(&self) -> &radroots_event::envelope::EventTags +pub fn radroots_event::generic_draft::GenericEventDraft::tags_as_vec(&self) -> alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> +pub fn radroots_event::generic_draft::GenericEventDraft::validate_for_authoring(&self) -> core::result::Result<(), radroots_event::draft::DraftError> +impl<'de> serde_core::de::Deserialize<'de> for radroots_event::generic_draft::GenericEventDraft +pub fn radroots_event::generic_draft::GenericEventDraft::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub mod radroots_event::id #[non_exhaustive] pub enum radroots_event::id::Nip01CoordinateParseError pub radroots_event::id::Nip01CoordinateParseError::Empty @@ -2161,6 +2192,7 @@ pub fn radroots_event::post::comment::Nip22CommentRoot::from(radroots_event::pos impl core::convert::From<radroots_event::post::comment::Nip22EventRootReference> for radroots_event::post::comment::Nip22CommentRoot pub fn radroots_event::post::comment::Nip22CommentRoot::from(radroots_event::post::comment::Nip22EventRootReference) -> Self pub enum radroots_event::post::comment::Nip22CommentRootKind +pub radroots_event::post::comment::Nip22CommentRootKind::Article pub radroots_event::post::comment::Nip22CommentRootKind::CalendarDateEvent pub radroots_event::post::comment::Nip22CommentRootKind::CalendarTimeEvent pub radroots_event::post::comment::Nip22CommentRootKind::ClassifiedListing @@ -2373,7 +2405,10 @@ pub radroots_event::post::AuthoredPostError::ImageFallbackHashMismatch pub radroots_event::post::AuthoredPostError::ImageMediaTypeInvalid pub radroots_event::post::AuthoredPostError::ImageMissing pub radroots_event::post::AuthoredPostError::ImageSizeInvalid -pub radroots_event::post::AuthoredPostError::ImageUrlMissingFromContent +pub radroots_event::post::AuthoredPostError::ImageUrlOccurrenceCount +pub radroots_event::post::AuthoredPostError::ImageUrlOccurrenceCount::actual: usize +pub radroots_event::post::AuthoredPostError::ImageUrlOccurrenceCount::expected: usize +pub radroots_event::post::AuthoredPostError::ImageUrlOverlap pub radroots_event::post::AuthoredPostError::TagBytesExceeded pub radroots_event::post::AuthoredPostError::TagBytesExceeded::actual: usize pub radroots_event::post::AuthoredPostError::TagBytesExceeded::max: usize @@ -3461,25 +3496,6 @@ pub fn radroots_event::envelope::EventEnvelope::tag_slices(&self) -> &[radroots_ pub fn radroots_event::envelope::EventEnvelope::tags(&self) -> &radroots_event::envelope::EventTags pub fn radroots_event::envelope::EventEnvelope::tags_as_vec(&self) -> alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> pub fn radroots_event::envelope::EventEnvelope::to_nip01_wire(&self) -> Nip01EventWire -pub struct radroots_event::EventDraft -impl radroots_event::draft::EventDraft -pub fn radroots_event::draft::EventDraft::content(&self) -> &str -pub fn radroots_event::draft::EventDraft::contract_id(&self) -> &str -pub fn radroots_event::draft::EventDraft::contract_registry_version(&self) -> u32 -pub fn radroots_event::draft::EventDraft::created_at(&self) -> radroots_event::envelope::EventTimestamp -pub fn radroots_event::draft::EventDraft::created_at_u64(&self) -> u64 -pub fn radroots_event::draft::EventDraft::expected_event_id(&self) -> &radroots_event::id::EventId -pub fn radroots_event::draft::EventDraft::expected_event_id_hex(&self) -> alloc::string::String -pub fn radroots_event::draft::EventDraft::expected_pubkey(&self) -> &radroots_identity::key::PublicKey -pub fn radroots_event::draft::EventDraft::kind(&self) -> radroots_event::envelope::EventKind -pub fn radroots_event::draft::EventDraft::kind_u32(&self) -> u32 -pub fn radroots_event::draft::EventDraft::new(impl core::convert::Into<alloc::string::String>, u32, u64, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, impl core::convert::Into<alloc::string::String>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event::draft::DraftError> -pub fn radroots_event::draft::EventDraft::nip01_preimage(&self) -> core::result::Result<alloc::string::String, radroots_event::draft::DraftError> -pub fn radroots_event::draft::EventDraft::tags(&self) -> &radroots_event::envelope::EventTags -pub fn radroots_event::draft::EventDraft::tags_as_vec(&self) -> alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> -pub fn radroots_event::draft::EventDraft::validate_for_signing(&self) -> core::result::Result<(), radroots_event::draft::DraftError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_event::draft::EventDraft -pub fn radroots_event::draft::EventDraft::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_event::EventId(_) impl radroots_event::id::EventId pub const fn radroots_event::id::EventId::as_bytes(&self) -> &[u8; 32] @@ -3530,6 +3546,25 @@ impl serde_core::ser::Serialize for radroots_event::envelope::EventTag pub fn radroots_event::envelope::EventTag::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer impl<'de> serde_core::de::Deserialize<'de> for radroots_event::envelope::EventTag pub fn radroots_event::envelope::EventTag::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_event::GenericEventDraft +impl radroots_event::generic_draft::GenericEventDraft +pub fn radroots_event::generic_draft::GenericEventDraft::content(&self) -> &str +pub const fn radroots_event::generic_draft::GenericEventDraft::contract(&self) -> &radroots_event::contract::ContractKey +pub fn radroots_event::generic_draft::GenericEventDraft::contract_id(&self) -> &str +pub const fn radroots_event::generic_draft::GenericEventDraft::created_at(&self) -> radroots_event::envelope::EventTimestamp +pub const fn radroots_event::generic_draft::GenericEventDraft::created_at_u64(&self) -> u64 +pub const fn radroots_event::generic_draft::GenericEventDraft::expected_event_id(&self) -> &radroots_event::id::EventId +pub fn radroots_event::generic_draft::GenericEventDraft::expected_event_id_hex(&self) -> alloc::string::String +pub const fn radroots_event::generic_draft::GenericEventDraft::expected_pubkey(&self) -> &radroots_identity::key::PublicKey +pub const fn radroots_event::generic_draft::GenericEventDraft::kind(&self) -> radroots_event::envelope::EventKind +pub const fn radroots_event::generic_draft::GenericEventDraft::kind_u32(&self) -> u32 +pub fn radroots_event::generic_draft::GenericEventDraft::new(impl core::convert::Into<alloc::string::String>, u32, u64, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, impl core::convert::Into<alloc::string::String>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event::draft::DraftError> +pub fn radroots_event::generic_draft::GenericEventDraft::nip01_preimage(&self) -> core::result::Result<alloc::string::String, radroots_event::draft::DraftError> +pub const fn radroots_event::generic_draft::GenericEventDraft::tags(&self) -> &radroots_event::envelope::EventTags +pub fn radroots_event::generic_draft::GenericEventDraft::tags_as_vec(&self) -> alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>> +pub fn radroots_event::generic_draft::GenericEventDraft::validate_for_authoring(&self) -> core::result::Result<(), radroots_event::draft::DraftError> +impl<'de> serde_core::de::Deserialize<'de> for radroots_event::generic_draft::GenericEventDraft +pub fn radroots_event::generic_draft::GenericEventDraft::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_event::SignedEvent impl radroots_event::draft::SignedEvent pub fn radroots_event::draft::SignedEvent::content(&self) -> &str diff --git a/docs/api/radroots_nostr.txt b/docs/api/radroots_nostr.txt @@ -88,13 +88,16 @@ pub fn radroots_nostr::event::EventAdapter<'a>::raw_id(&'a self) -> alloc::strin pub fn radroots_nostr::event::EventAdapter<'a>::raw_kind(&'a self) -> u32 pub struct radroots_nostr::event::ExternalSigningRequest impl radroots_nostr::event::ExternalSigningRequest +pub const fn radroots_nostr::event::ExternalSigningRequest::authored_plan(&self) -> core::option::Option<&radroots_event_codec::authoring::AuthoredEventPlan> pub fn radroots_nostr::event::ExternalSigningRequest::complete(self, radroots_nostr::event::Event) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub fn radroots_nostr::event::ExternalSigningRequest::expected_event_id(&self) -> radroots_nostr::event::EventId pub fn radroots_nostr::event::ExternalSigningRequest::expected_public_key(&self) -> nostr::key::public_key::PublicKey +pub fn radroots_nostr::event::ExternalSigningRequest::from_authored_plan(radroots_event_codec::authoring::AuthoredEventPlan) -> core::result::Result<Self, radroots_nostr::Error> impl serde_core::ser::Serialize for radroots_nostr::event::ExternalSigningRequest pub fn radroots_nostr::event::ExternalSigningRequest::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer pub struct radroots_nostr::event::FoodAvailabilityBuilder impl radroots_nostr::event::FoodAvailabilityBuilder +pub fn radroots_nostr::event::FoodAvailabilityBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::FoodAvailabilityBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::GenericBuilder impl radroots_nostr::event::GenericBuilder @@ -111,22 +114,27 @@ pub fn radroots_nostr::event::GenericBuilder::text_note(impl core::convert::Into pub struct radroots_nostr::event::Nip09DeletionRequestBuilder impl radroots_nostr::event::Nip09DeletionRequestBuilder pub fn radroots_nostr::event::Nip09DeletionRequestBuilder::custom_created_at(self, radroots_nostr::event::Timestamp) -> Self +pub fn radroots_nostr::event::Nip09DeletionRequestBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::Nip09DeletionRequestBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::Nip10ReplyBuilder impl radroots_nostr::event::Nip10ReplyBuilder pub fn radroots_nostr::event::Nip10ReplyBuilder::custom_created_at(self, radroots_nostr::event::Timestamp) -> Self +pub fn radroots_nostr::event::Nip10ReplyBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::Nip10ReplyBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::Nip22CommentBuilder impl radroots_nostr::event::Nip22CommentBuilder pub fn radroots_nostr::event::Nip22CommentBuilder::custom_created_at(self, radroots_nostr::event::Timestamp) -> Self +pub fn radroots_nostr::event::Nip22CommentBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::Nip22CommentBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::PostBuilder impl radroots_nostr::event::PostBuilder pub fn radroots_nostr::event::PostBuilder::custom_created_at(self, radroots_nostr::event::Timestamp) -> Self +pub fn radroots_nostr::event::PostBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::PostBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::ProfileBuilder impl radroots_nostr::event::ProfileBuilder pub fn radroots_nostr::event::ProfileBuilder::custom_created_at(self, radroots_nostr::event::Timestamp) -> Self +pub fn radroots_nostr::event::ProfileBuilder::into_external_signing_request(self, nostr::key::public_key::PublicKey) -> core::result::Result<radroots_nostr::event::ExternalSigningRequest, radroots_nostr::Error> pub fn radroots_nostr::event::ProfileBuilder::sign_with_keys(self, &nostr::key::Keys) -> core::result::Result<radroots_nostr::event::Event, radroots_nostr::Error> pub struct radroots_nostr::event::SignatureVerifier impl radroots_event::verification::SignatureVerifier for radroots_nostr::event::SignatureVerifier @@ -259,7 +267,6 @@ pub fn radroots_nostr::signing::LocalSigner::fmt(&self, &mut core::fmt::Formatte impl radroots_signing::signer::Signer for radroots_nostr::signing::LocalSigner pub fn radroots_nostr::signing::LocalSigner::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> pub fn radroots_nostr::signing::LocalSigner::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> -pub fn radroots_nostr::signing::sign_frozen_draft(&nostr::key::Keys, &radroots_event::draft::EventDraft) -> core::result::Result<radroots_event::draft::SignedEvent, radroots_nostr::Error> pub mod radroots_nostr::tag pub enum radroots_nostr::tag::ResolveError pub radroots_nostr::tag::ResolveError::DecryptionError(alloc::string::String) @@ -283,6 +290,7 @@ pub type radroots_nostr::tag::Tag = nostr::event::tag::Tag pub type radroots_nostr::tag::TagKind<'a> = nostr::event::tag::kind::TagKind<'a> pub type radroots_nostr::tag::TagStandard = nostr::event::tag::standard::TagStandard pub enum radroots_nostr::Error +pub radroots_nostr::Error::AuthoredPlan(radroots_event_codec::authoring::typed::AuthoredPlanError) pub radroots_nostr::Error::CoordinateConversion pub radroots_nostr::Error::DraftError(radroots_event::draft::DraftError) pub radroots_nostr::Error::EventBuildError(nostr::event::builder::Error) @@ -297,14 +305,10 @@ pub radroots_nostr::Error::ExternalSigningEventIdMismatch pub radroots_nostr::Error::ExternalSigningEventIdMismatch::actual: nostr::event::id::EventId pub radroots_nostr::Error::ExternalSigningEventIdMismatch::expected: nostr::event::id::EventId pub radroots_nostr::Error::ExternalSigningEventInvalid(nostr::event::error::Error) +pub radroots_nostr::Error::ExternalSigningPlanMismatch +pub radroots_nostr::Error::ExternalSigningPlanMismatch::field: &'static str pub radroots_nostr::Error::FilterTagError(alloc::string::String) pub radroots_nostr::Error::FoodAvailabilityEncode(radroots_event_codec::food_availability::authored::RadrootsFoodAvailabilityEncodeError) -pub radroots_nostr::Error::FrozenDraftEventIdMismatch -pub radroots_nostr::Error::FrozenDraftEventIdMismatch::actual_event_id: alloc::string::String -pub radroots_nostr::Error::FrozenDraftEventIdMismatch::expected_event_id: alloc::string::String -pub radroots_nostr::Error::FrozenDraftPubkeyMismatch -pub radroots_nostr::Error::FrozenDraftPubkeyMismatch::actual_pubkey: alloc::string::String -pub radroots_nostr::Error::FrozenDraftPubkeyMismatch::expected_pubkey: alloc::string::String pub radroots_nostr::Error::InvalidEncryptedSecretKey pub radroots_nostr::Error::InvalidNpub pub radroots_nostr::Error::InvalidPublicKey diff --git a/docs/api/radroots_signing.txt b/docs/api/radroots_signing.txt @@ -2,7 +2,7 @@ pub mod radroots_signing pub mod radroots_signing::actor #[non_exhaustive] pub enum radroots_signing::actor::ActorSelector pub radroots_signing::actor::ActorSelector::Account(radroots_identity::account::AccountId) -pub radroots_signing::actor::ActorSelector::DraftExpectedPublicKey +pub radroots_signing::actor::ActorSelector::PlanAuthorPublicKey pub radroots_signing::actor::ActorSelector::PublicKey(radroots_identity::key::PublicKey) pub radroots_signing::actor::ActorSelector::SelectedAccount #[non_exhaustive] pub enum radroots_signing::actor::ActorSource @@ -27,6 +27,31 @@ pub const fn radroots_signing::actor::ActorResolutionRequest::expected_public_ke pub const fn radroots_signing::actor::ActorResolutionRequest::new(radroots_signing::actor::ActorSelector, radroots_event::contract::registry_v7::AuthorRole, radroots_identity::key::PublicKey) -> Self pub const fn radroots_signing::actor::ActorResolutionRequest::required_role(&self) -> radroots_event::contract::registry_v7::AuthorRole pub const fn radroots_signing::actor::ActorResolutionRequest::selector(&self) -> radroots_signing::actor::ActorSelector +pub mod radroots_signing::authorization +#[non_exhaustive] pub enum radroots_signing::authorization::CurrentAuthoringDecision +pub radroots_signing::authorization::CurrentAuthoringDecision::Allowed +pub radroots_signing::authorization::CurrentAuthoringDecision::AllowedDeprecated +pub radroots_signing::authorization::CurrentAuthoringDecision::AllowedDeprecated::warning_code: &'static str +pub radroots_signing::authorization::CurrentAuthoringDecision::Blocked +pub radroots_signing::authorization::CurrentAuthoringDecision::Blocked::code: &'static str +pub radroots_signing::authorization::CurrentAuthoringDecision::Revoked +pub radroots_signing::authorization::CurrentAuthoringDecision::Revoked::code: &'static str +#[non_exhaustive] pub enum radroots_signing::authorization::DeprecatedPlanPolicy +pub radroots_signing::authorization::DeprecatedPlanPolicy::Allow +pub radroots_signing::authorization::DeprecatedPlanPolicy::Deny +#[non_exhaustive] pub enum radroots_signing::authorization::ManagedSigningPolicy +pub radroots_signing::authorization::ManagedSigningPolicy::AccountBackedOnly +pub radroots_signing::authorization::ManagedSigningPolicy::AnyValidatedSource +pub radroots_signing::authorization::ManagedSigningPolicy::LocalAccountOnly +impl radroots_signing::authorization::ManagedSigningPolicy +pub const fn radroots_signing::authorization::ManagedSigningPolicy::permits(self, &radroots_signing::actor::Actor) -> bool +pub struct radroots_signing::authorization::CurrentRegistryAuthority +impl radroots_signing::authorization::CurrentAuthoringAuthority for radroots_signing::authorization::CurrentRegistryAuthority +pub fn radroots_signing::authorization::CurrentRegistryAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision +pub trait radroots_signing::authorization::CurrentAuthoringAuthority: core::marker::Send + core::marker::Sync +pub fn radroots_signing::authorization::CurrentAuthoringAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision +impl radroots_signing::authorization::CurrentAuthoringAuthority for radroots_signing::authorization::CurrentRegistryAuthority +pub fn radroots_signing::authorization::CurrentRegistryAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision pub mod radroots_signing::capability #[non_exhaustive] pub enum radroots_signing::capability::CancellationSupport pub radroots_signing::capability::CancellationSupport::BeforeAndAfterPublication @@ -40,7 +65,8 @@ impl radroots_signing::capability::SignerCapability pub const fn radroots_signing::capability::SignerCapability::cancellation(self) -> radroots_signing::capability::CancellationSupport pub const fn radroots_signing::capability::SignerCapability::kind(self) -> radroots_signing::capability::SignerKind pub const fn radroots_signing::capability::SignerCapability::may_require_authentication(self) -> bool -pub const fn radroots_signing::capability::SignerCapability::new(radroots_signing::capability::SignerKind, radroots_signing::capability::CancellationSupport, bool, bool) -> Self +pub const fn radroots_signing::capability::SignerCapability::new(radroots_signing::capability::SignerKind, radroots_signing::recovery::ReplayCapability, radroots_signing::capability::CancellationSupport, bool, bool) -> Self +pub const fn radroots_signing::capability::SignerCapability::replay(self) -> radroots_signing::recovery::ReplayCapability pub const fn radroots_signing::capability::SignerCapability::reports_progress(self) -> bool pub mod radroots_signing::error #[non_exhaustive] pub enum radroots_signing::error::Kind @@ -74,8 +100,10 @@ pub const fn radroots_signing::error::Error::descriptor(&self) -> radroots_signi pub const fn radroots_signing::error::Error::kind(&self) -> radroots_signing::error::Kind pub const fn radroots_signing::error::Error::new(radroots_signing::error::Kind) -> Self pub const fn radroots_signing::error::Error::recovery_actions(&self) -> &'static [radroots_protocol::error::v1::RecoveryAction] +pub const fn radroots_signing::error::Error::remote_effect(&self) -> radroots_signing::recovery::RemoteEffect pub const fn radroots_signing::error::Error::retryable(&self) -> bool pub fn radroots_signing::error::Error::to_report(&self, core::option::Option<radroots_protocol::runtime::v1::OperationId>) -> radroots_protocol::error::v1::ErrorReport +pub const fn radroots_signing::error::Error::with_possible_remote_effect(self) -> Self pub fn radroots_signing::error::Error::with_source<E>(radroots_signing::error::Kind, E) -> Self where E: core::error::Error + core::marker::Send + core::marker::Sync + 'static impl core::convert::From<&radroots_signing::error::Error> for radroots_protocol::error::v1::ErrorReport pub fn radroots_protocol::error::v1::ErrorReport::from(&radroots_signing::error::Error) -> Self @@ -86,32 +114,93 @@ pub fn radroots_signing::error::Error::fmt(&self, &mut core::fmt::Formatter<'_>) impl core::fmt::Display for radroots_signing::error::Error pub fn radroots_signing::error::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub const radroots_signing::error::CATALOG: &[radroots_signing::error::Descriptor] +pub mod radroots_signing::identity +pub struct radroots_signing::identity::AuthoredArtifactId(_) +impl radroots_signing::identity::AuthoredArtifactId +pub const fn radroots_signing::identity::AuthoredArtifactId::as_bytes(&self) -> &[u8; 16] +pub fn radroots_signing::identity::AuthoredArtifactId::new([u8; 16]) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::identity::AuthoredArtifactId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::AuthoredArtifactId +pub fn radroots_signing::identity::AuthoredArtifactId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_signing::identity::SignerRequestId(_) +impl radroots_signing::identity::SignerRequestId +pub const fn radroots_signing::identity::SignerRequestId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_signing::identity::SignerRequestId::derive(radroots_signing::identity::AuthoredArtifactId, radroots_event_codec::authoring::PlanDigest) -> Self +pub fn radroots_signing::identity::SignerRequestId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::SignerRequestId +pub fn radroots_signing::identity::SignerRequestId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_signing::identity::SigningIntentId +impl radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::identity::SigningIntentId::artifact_id(self) -> radroots_signing::identity::AuthoredArtifactId +pub const fn radroots_signing::identity::SigningIntentId::new(radroots_signing::identity::SigningOperationId, radroots_signing::identity::AuthoredArtifactId) -> Self +pub const fn radroots_signing::identity::SigningIntentId::operation_id(self) -> radroots_signing::identity::SigningOperationId +pub struct radroots_signing::identity::SigningOperationId(_) +impl radroots_signing::identity::SigningOperationId +pub const fn radroots_signing::identity::SigningOperationId::as_bytes(&self) -> &[u8; 16] +pub fn radroots_signing::identity::SigningOperationId::new([u8; 16]) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::identity::SigningOperationId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::SigningOperationId +pub fn radroots_signing::identity::SigningOperationId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_signing::receipt #[non_exhaustive] pub struct radroots_signing::receipt::SignReceipt impl radroots_signing::receipt::SignReceipt -pub const fn radroots_signing::receipt::SignReceipt::completed_at_unix(&self) -> u64 +pub const fn radroots_signing::receipt::SignReceipt::completed_at_unix_ms(&self) -> u64 pub fn radroots_signing::receipt::SignReceipt::from_signed_event(&radroots_signing::request::SignRequest, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_signing::error::Error> -pub const fn radroots_signing::receipt::SignReceipt::operation_id(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::receipt::SignReceipt::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::receipt::SignReceipt::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId pub const fn radroots_signing::receipt::SignReceipt::signed_event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_signing::receipt::SignReceipt::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId impl core::fmt::Debug for radroots_signing::receipt::SignReceipt pub fn radroots_signing::receipt::SignReceipt::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub mod radroots_signing::recovery +#[non_exhaustive] pub enum radroots_signing::recovery::RecoveryDisposition +pub radroots_signing::recovery::RecoveryDisposition::Failed +pub radroots_signing::recovery::RecoveryDisposition::Indeterminate +pub radroots_signing::recovery::RecoveryDisposition::RetryExactRequest +pub radroots_signing::recovery::RecoveryDisposition::RetryLocal +#[non_exhaustive] pub enum radroots_signing::recovery::RemoteEffect +pub radroots_signing::recovery::RemoteEffect::MayHaveOccurred +pub radroots_signing::recovery::RemoteEffect::None +#[non_exhaustive] pub enum radroots_signing::recovery::ReplayCapability +pub radroots_signing::recovery::ReplayCapability::ExactReplayByRequestId +pub radroots_signing::recovery::ReplayCapability::LocalReplaySafe +pub radroots_signing::recovery::ReplayCapability::NonReplayable +pub const fn radroots_signing::recovery::recovery_disposition(radroots_signing::recovery::ReplayCapability, radroots_signing::recovery::RemoteEffect, bool) -> radroots_signing::recovery::RecoveryDisposition pub mod radroots_signing::request #[non_exhaustive] pub enum radroots_signing::request::CancellationPolicy pub radroots_signing::request::CancellationPolicy::LocalCooperative pub radroots_signing::request::CancellationPolicy::PreservePublishedRequest +pub struct radroots_signing::request::CancellationSignal(_) +impl radroots_signing::request::CancellationSignal +pub fn radroots_signing::request::CancellationSignal::cancel(&self) +pub fn radroots_signing::request::CancellationSignal::is_cancelled(&self) -> bool +pub fn radroots_signing::request::CancellationSignal::new() -> Self #[non_exhaustive] pub struct radroots_signing::request::SignPolicy impl radroots_signing::request::SignPolicy +pub const fn radroots_signing::request::SignPolicy::allowing_deprecated(self) -> Self pub const fn radroots_signing::request::SignPolicy::cancellation(self) -> radroots_signing::request::CancellationPolicy -pub const fn radroots_signing::request::SignPolicy::deadline_unix(self) -> u64 +pub const fn radroots_signing::request::SignPolicy::deadline_unix_ms(self) -> u64 +pub const fn radroots_signing::request::SignPolicy::deprecated_plan(self) -> radroots_signing::authorization::DeprecatedPlanPolicy +pub const fn radroots_signing::request::SignPolicy::managed_signing(self) -> radroots_signing::authorization::ManagedSigningPolicy pub const fn radroots_signing::request::SignPolicy::new(u64, radroots_signing::request::CancellationPolicy) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::request::SignPolicy::with_managed_signing_policy(self, radroots_signing::authorization::ManagedSigningPolicy) -> Self +impl<'de> serde_core::de::Deserialize<'de> for radroots_signing::request::SignPolicy +pub fn radroots_signing::request::SignPolicy::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_signing::request::SignRequest impl radroots_signing::request::SignRequest pub const fn radroots_signing::request::SignRequest::actor(&self) -> &radroots_signing::actor::Actor -pub const fn radroots_signing::request::SignRequest::draft(&self) -> &radroots_event::draft::EventDraft -pub fn radroots_signing::request::SignRequest::new(radroots_protocol::runtime::v1::OperationId, radroots_signing::actor::Actor, radroots_event::draft::EventDraft, radroots_signing::request::SignPolicy) -> core::result::Result<Self, radroots_signing::error::Error> -pub const fn radroots_signing::request::SignRequest::operation_id(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::request::SignRequest::authorization(&self) -> radroots_signing::authorization::CurrentAuthoringDecision +pub const fn radroots_signing::request::SignRequest::cancellation_signal(&self) -> &radroots_signing::request::CancellationSignal +pub fn radroots_signing::request::SignRequest::ensure_active(&self, u64) -> core::result::Result<(), radroots_signing::error::Error> +pub const fn radroots_signing::request::SignRequest::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub fn radroots_signing::request::SignRequest::new(radroots_protocol::runtime::v1::OperationId, radroots_signing::identity::SigningIntentId, radroots_signing::actor::Actor, radroots_event_codec::authoring::AuthoredEventPlan, radroots_signing::request::SignPolicy) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::request::SignRequest::new_with_authority(radroots_protocol::runtime::v1::OperationId, radroots_signing::identity::SigningIntentId, radroots_signing::actor::Actor, radroots_event_codec::authoring::AuthoredEventPlan, radroots_signing::request::SignPolicy, &dyn radroots_signing::authorization::CurrentAuthoringAuthority) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::request::SignRequest::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::request::SignRequest::plan(&self) -> &radroots_event_codec::authoring::AuthoredEventPlan pub const fn radroots_signing::request::SignRequest::policy(&self) -> radroots_signing::request::SignPolicy pub fn radroots_signing::request::SignRequest::report_progress(&self, &radroots_signing::status::SignProgress) +pub const fn radroots_signing::request::SignRequest::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId +pub fn radroots_signing::request::SignRequest::with_cancellation_signal(self, radroots_signing::request::CancellationSignal) -> Self pub fn radroots_signing::request::SignRequest::with_progress_observer(self, alloc::sync::Arc<dyn radroots_signing::request::ProgressObserver>) -> Self impl core::fmt::Debug for radroots_signing::request::SignRequest pub fn radroots_signing::request::SignRequest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result @@ -122,6 +211,7 @@ pub trait radroots_signing::signer::Signer: core::marker::Send + core::marker::S pub fn radroots_signing::signer::Signer::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> pub fn radroots_signing::signer::Signer::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> pub type radroots_signing::signer::BoxFuture<'a, T> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = T> + core::marker::Send + 'a)>> +pub type radroots_signing::signer::DynSigner = alloc::sync::Arc<dyn radroots_signing::signer::Signer> pub mod radroots_signing::status #[non_exhaustive] pub enum radroots_signing::status::SignProgressStage pub radroots_signing::status::SignProgressStage::AwaitingAuthentication @@ -161,6 +251,14 @@ pub fn radroots_signing::status::SignerStatus::capabilities(&self) -> &[radroots pub fn radroots_signing::status::SignerStatus::new(radroots_signing::status::SignerAvailability, alloc::vec::Vec<radroots_signing::capability::SignerCapability>, core::option::Option<radroots_signing::status::SignProgress>) -> Self pub const fn radroots_signing::status::SignerStatus::progress(&self) -> core::option::Option<&radroots_signing::status::SignProgress> pub const fn radroots_signing::status::SignerStatus::unavailable() -> Self +#[non_exhaustive] pub enum radroots_signing::CurrentAuthoringDecision +pub radroots_signing::CurrentAuthoringDecision::Allowed +pub radroots_signing::CurrentAuthoringDecision::AllowedDeprecated +pub radroots_signing::CurrentAuthoringDecision::AllowedDeprecated::warning_code: &'static str +pub radroots_signing::CurrentAuthoringDecision::Blocked +pub radroots_signing::CurrentAuthoringDecision::Blocked::code: &'static str +pub radroots_signing::CurrentAuthoringDecision::Revoked +pub radroots_signing::CurrentAuthoringDecision::Revoked::code: &'static str pub struct radroots_signing::Actor impl radroots_signing::actor::Actor pub const fn radroots_signing::actor::Actor::account_id(&self) -> core::option::Option<radroots_identity::account::AccountId> @@ -170,6 +268,13 @@ pub const fn radroots_signing::actor::Actor::public_key(&self) -> radroots_ident pub const fn radroots_signing::actor::Actor::roles(&self) -> &alloc::collections::btree::set::BTreeSet<radroots_event::contract::registry_v7::AuthorRole> pub fn radroots_signing::actor::Actor::satisfies(&self, radroots_event::contract::registry_v7::AuthorRole) -> bool pub const fn radroots_signing::actor::Actor::source(&self) -> radroots_signing::actor::ActorSource +pub struct radroots_signing::AuthoredArtifactId(_) +impl radroots_signing::identity::AuthoredArtifactId +pub const fn radroots_signing::identity::AuthoredArtifactId::as_bytes(&self) -> &[u8; 16] +pub fn radroots_signing::identity::AuthoredArtifactId::new([u8; 16]) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::identity::AuthoredArtifactId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::AuthoredArtifactId +pub fn radroots_signing::identity::AuthoredArtifactId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_signing::Error impl radroots_signing::error::Error pub const fn radroots_signing::error::Error::class(&self) -> radroots_protocol::error::v1::Class @@ -178,8 +283,10 @@ pub const fn radroots_signing::error::Error::descriptor(&self) -> radroots_signi pub const fn radroots_signing::error::Error::kind(&self) -> radroots_signing::error::Kind pub const fn radroots_signing::error::Error::new(radroots_signing::error::Kind) -> Self pub const fn radroots_signing::error::Error::recovery_actions(&self) -> &'static [radroots_protocol::error::v1::RecoveryAction] +pub const fn radroots_signing::error::Error::remote_effect(&self) -> radroots_signing::recovery::RemoteEffect pub const fn radroots_signing::error::Error::retryable(&self) -> bool pub fn radroots_signing::error::Error::to_report(&self, core::option::Option<radroots_protocol::runtime::v1::OperationId>) -> radroots_protocol::error::v1::ErrorReport +pub const fn radroots_signing::error::Error::with_possible_remote_effect(self) -> Self pub fn radroots_signing::error::Error::with_source<E>(radroots_signing::error::Kind, E) -> Self where E: core::error::Error + core::marker::Send + core::marker::Sync + 'static impl core::convert::From<&radroots_signing::error::Error> for radroots_protocol::error::v1::ErrorReport pub fn radroots_protocol::error::v1::ErrorReport::from(&radroots_signing::error::Error) -> Self @@ -191,23 +298,39 @@ impl core::fmt::Display for radroots_signing::error::Error pub fn radroots_signing::error::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result #[non_exhaustive] pub struct radroots_signing::SignReceipt impl radroots_signing::receipt::SignReceipt -pub const fn radroots_signing::receipt::SignReceipt::completed_at_unix(&self) -> u64 +pub const fn radroots_signing::receipt::SignReceipt::completed_at_unix_ms(&self) -> u64 pub fn radroots_signing::receipt::SignReceipt::from_signed_event(&radroots_signing::request::SignRequest, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_signing::error::Error> -pub const fn radroots_signing::receipt::SignReceipt::operation_id(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::receipt::SignReceipt::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::receipt::SignReceipt::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId pub const fn radroots_signing::receipt::SignReceipt::signed_event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_signing::receipt::SignReceipt::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId impl core::fmt::Debug for radroots_signing::receipt::SignReceipt pub fn radroots_signing::receipt::SignReceipt::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_signing::SignRequest impl radroots_signing::request::SignRequest pub const fn radroots_signing::request::SignRequest::actor(&self) -> &radroots_signing::actor::Actor -pub const fn radroots_signing::request::SignRequest::draft(&self) -> &radroots_event::draft::EventDraft -pub fn radroots_signing::request::SignRequest::new(radroots_protocol::runtime::v1::OperationId, radroots_signing::actor::Actor, radroots_event::draft::EventDraft, radroots_signing::request::SignPolicy) -> core::result::Result<Self, radroots_signing::error::Error> -pub const fn radroots_signing::request::SignRequest::operation_id(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::request::SignRequest::authorization(&self) -> radroots_signing::authorization::CurrentAuthoringDecision +pub const fn radroots_signing::request::SignRequest::cancellation_signal(&self) -> &radroots_signing::request::CancellationSignal +pub fn radroots_signing::request::SignRequest::ensure_active(&self, u64) -> core::result::Result<(), radroots_signing::error::Error> +pub const fn radroots_signing::request::SignRequest::intent_id(&self) -> radroots_signing::identity::SigningIntentId +pub fn radroots_signing::request::SignRequest::new(radroots_protocol::runtime::v1::OperationId, radroots_signing::identity::SigningIntentId, radroots_signing::actor::Actor, radroots_event_codec::authoring::AuthoredEventPlan, radroots_signing::request::SignPolicy) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::request::SignRequest::new_with_authority(radroots_protocol::runtime::v1::OperationId, radroots_signing::identity::SigningIntentId, radroots_signing::actor::Actor, radroots_event_codec::authoring::AuthoredEventPlan, radroots_signing::request::SignPolicy, &dyn radroots_signing::authorization::CurrentAuthoringAuthority) -> core::result::Result<Self, radroots_signing::error::Error> +pub const fn radroots_signing::request::SignRequest::operation_kind(&self) -> radroots_protocol::runtime::v1::OperationId +pub const fn radroots_signing::request::SignRequest::plan(&self) -> &radroots_event_codec::authoring::AuthoredEventPlan pub const fn radroots_signing::request::SignRequest::policy(&self) -> radroots_signing::request::SignPolicy pub fn radroots_signing::request::SignRequest::report_progress(&self, &radroots_signing::status::SignProgress) +pub const fn radroots_signing::request::SignRequest::signer_request_id(&self) -> radroots_signing::identity::SignerRequestId +pub fn radroots_signing::request::SignRequest::with_cancellation_signal(self, radroots_signing::request::CancellationSignal) -> Self pub fn radroots_signing::request::SignRequest::with_progress_observer(self, alloc::sync::Arc<dyn radroots_signing::request::ProgressObserver>) -> Self impl core::fmt::Debug for radroots_signing::request::SignRequest pub fn radroots_signing::request::SignRequest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_signing::SignerRequestId(_) +impl radroots_signing::identity::SignerRequestId +pub const fn radroots_signing::identity::SignerRequestId::as_bytes(&self) -> &[u8; 32] +pub fn radroots_signing::identity::SignerRequestId::derive(radroots_signing::identity::AuthoredArtifactId, radroots_event_codec::authoring::PlanDigest) -> Self +pub fn radroots_signing::identity::SignerRequestId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::SignerRequestId +pub fn radroots_signing::identity::SignerRequestId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result #[non_exhaustive] pub struct radroots_signing::SignerStatus impl radroots_signing::status::SignerStatus pub const fn radroots_signing::status::SignerStatus::availability(&self) -> radroots_signing::status::SignerAvailability @@ -215,6 +338,22 @@ pub fn radroots_signing::status::SignerStatus::capabilities(&self) -> &[radroots pub fn radroots_signing::status::SignerStatus::new(radroots_signing::status::SignerAvailability, alloc::vec::Vec<radroots_signing::capability::SignerCapability>, core::option::Option<radroots_signing::status::SignProgress>) -> Self pub const fn radroots_signing::status::SignerStatus::progress(&self) -> core::option::Option<&radroots_signing::status::SignProgress> pub const fn radroots_signing::status::SignerStatus::unavailable() -> Self +pub struct radroots_signing::SigningIntentId +impl radroots_signing::identity::SigningIntentId +pub const fn radroots_signing::identity::SigningIntentId::artifact_id(self) -> radroots_signing::identity::AuthoredArtifactId +pub const fn radroots_signing::identity::SigningIntentId::new(radroots_signing::identity::SigningOperationId, radroots_signing::identity::AuthoredArtifactId) -> Self +pub const fn radroots_signing::identity::SigningIntentId::operation_id(self) -> radroots_signing::identity::SigningOperationId +pub struct radroots_signing::SigningOperationId(_) +impl radroots_signing::identity::SigningOperationId +pub const fn radroots_signing::identity::SigningOperationId::as_bytes(&self) -> &[u8; 16] +pub fn radroots_signing::identity::SigningOperationId::new([u8; 16]) -> core::result::Result<Self, radroots_signing::error::Error> +pub fn radroots_signing::identity::SigningOperationId::to_hex(self) -> alloc::string::String +impl core::fmt::Debug for radroots_signing::identity::SigningOperationId +pub fn radroots_signing::identity::SigningOperationId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub trait radroots_signing::CurrentAuthoringAuthority: core::marker::Send + core::marker::Sync +pub fn radroots_signing::CurrentAuthoringAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision +impl radroots_signing::authorization::CurrentAuthoringAuthority for radroots_signing::authorization::CurrentRegistryAuthority +pub fn radroots_signing::authorization::CurrentRegistryAuthority::evaluate(&self, &radroots_event_codec::authoring::AuthoredEventPlan) -> radroots_signing::authorization::CurrentAuthoringDecision pub trait radroots_signing::Signer: core::marker::Send + core::marker::Sync pub fn radroots_signing::Signer::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> pub fn radroots_signing::Signer::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> diff --git a/docs/api/radroots_storage.txt b/docs/api/radroots_storage.txt @@ -4,36 +4,17 @@ pub enum radroots_storage::atomic::AtomicCommitDisposition pub radroots_storage::atomic::AtomicCommitDisposition::Committed pub radroots_storage::atomic::AtomicCommitDisposition::Replay pub enum radroots_storage::atomic::AtomicCommitOutcome -pub radroots_storage::atomic::AtomicCommitOutcome::Delivered -pub radroots_storage::atomic::AtomicCommitOutcome::Delivered::outbox: alloc::boxed::Box<radroots_storage::outbox::OutboxRecord> -pub radroots_storage::atomic::AtomicCommitOutcome::Enqueued -pub radroots_storage::atomic::AtomicCommitOutcome::Enqueued::admission: radroots_storage::event::AdmissionReceipt -pub radroots_storage::atomic::AtomicCommitOutcome::Enqueued::journal: radroots_storage::journal::OperationRecord -pub radroots_storage::atomic::AtomicCommitOutcome::Enqueued::outbox: alloc::boxed::Box<radroots_storage::outbox::OutboxRecord> pub radroots_storage::atomic::AtomicCommitOutcome::Ingested pub radroots_storage::atomic::AtomicCommitOutcome::Ingested::admission: radroots_storage::event::AdmissionReceipt pub radroots_storage::atomic::AtomicCommitOutcome::Ingested::projection: core::option::Option<alloc::boxed::Box<radroots_storage::projection::ProjectionStatus>> -pub radroots_storage::atomic::AtomicCommitOutcome::Prepared -pub radroots_storage::atomic::AtomicCommitOutcome::Prepared::journal: radroots_storage::journal::OperationRecord -pub radroots_storage::atomic::AtomicCommitOutcome::Signed -pub radroots_storage::atomic::AtomicCommitOutcome::Signed::event_id: radroots_event::id::EventId -pub radroots_storage::atomic::AtomicCommitOutcome::Signed::journal: radroots_storage::journal::OperationRecord impl radroots_storage::atomic::AtomicCommitOutcome pub const fn radroots_storage::atomic::AtomicCommitOutcome::kind(&self) -> radroots_storage::atomic::AtomicWorkflowKind pub enum radroots_storage::atomic::AtomicWorkflow -pub radroots_storage::atomic::AtomicWorkflow::Delivered(alloc::boxed::Box<radroots_storage::outbox::DeliveryAttemptEvidence>) -pub radroots_storage::atomic::AtomicWorkflow::Enqueued(alloc::boxed::Box<radroots_storage::atomic::CommitEnqueued>) pub radroots_storage::atomic::AtomicWorkflow::Ingested(alloc::boxed::Box<radroots_storage::atomic::CommitIngested>) -pub radroots_storage::atomic::AtomicWorkflow::Prepared(radroots_storage::journal::PrepareOperation) -pub radroots_storage::atomic::AtomicWorkflow::Signed(alloc::boxed::Box<radroots_storage::atomic::CommitSigned>) impl radroots_storage::atomic::AtomicWorkflow pub const fn radroots_storage::atomic::AtomicWorkflow::kind(&self) -> radroots_storage::atomic::AtomicWorkflowKind pub enum radroots_storage::atomic::AtomicWorkflowKind -pub radroots_storage::atomic::AtomicWorkflowKind::Delivered -pub radroots_storage::atomic::AtomicWorkflowKind::Enqueued pub radroots_storage::atomic::AtomicWorkflowKind::Ingested -pub radroots_storage::atomic::AtomicWorkflowKind::Prepared -pub radroots_storage::atomic::AtomicWorkflowKind::Signed pub struct radroots_storage::atomic::AtomicCommit impl radroots_storage::atomic::AtomicCommit pub const fn radroots_storage::atomic::AtomicCommit::commit_id(&self) -> radroots_storage::atomic::AtomicCommitId @@ -55,33 +36,335 @@ pub const fn radroots_storage::atomic::AtomicCommitReceipt::commit_id(&self) -> pub const fn radroots_storage::atomic::AtomicCommitReceipt::committed_at_unix_ms(&self) -> u64 pub const fn radroots_storage::atomic::AtomicCommitReceipt::digest(&self) -> radroots_storage::atomic::AtomicCommitDigest pub const fn radroots_storage::atomic::AtomicCommitReceipt::disposition(&self) -> radroots_storage::atomic::AtomicCommitDisposition +pub fn radroots_storage::atomic::AtomicCommitReceipt::from_durable_parts(radroots_storage::atomic::AtomicCommitId, radroots_storage::atomic::AtomicCommitDigest, radroots_storage::atomic::AtomicCommitDisposition, u64, u64, radroots_storage::atomic::AtomicWorkflowKind, radroots_storage::atomic::AtomicCommitOutcome) -> core::result::Result<Self, radroots_storage::Error> pub fn radroots_storage::atomic::AtomicCommitReceipt::new(&radroots_storage::atomic::AtomicCommit, radroots_storage::atomic::AtomicCommitDisposition, u64, radroots_storage::atomic::AtomicCommitOutcome) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::atomic::AtomicCommitReceipt::outcome(&self) -> &radroots_storage::atomic::AtomicCommitOutcome -pub struct radroots_storage::atomic::CommitEnqueued -impl radroots_storage::atomic::CommitEnqueued -pub const fn radroots_storage::atomic::CommitEnqueued::admission(&self) -> &radroots_storage::event::EventAdmission -pub const fn radroots_storage::atomic::CommitEnqueued::committed_at_unix_ms(&self) -> u64 -pub const fn radroots_storage::atomic::CommitEnqueued::expected_revision(&self) -> radroots_storage::journal::JournalRevision -pub const fn radroots_storage::atomic::CommitEnqueued::instance_id(&self) -> radroots_storage::journal::OperationInstanceId -pub fn radroots_storage::atomic::CommitEnqueued::new(radroots_storage::journal::OperationInstanceId, radroots_storage::journal::JournalRevision, radroots_storage::event::EventAdmission, radroots_storage::outbox::EnqueueOutboxItem, u64) -> core::result::Result<Self, radroots_storage::Error> -pub const fn radroots_storage::atomic::CommitEnqueued::outbox(&self) -> &radroots_storage::outbox::EnqueueOutboxItem pub struct radroots_storage::atomic::CommitIngested impl radroots_storage::atomic::CommitIngested pub const fn radroots_storage::atomic::CommitIngested::admission(&self) -> &radroots_storage::event::EventAdmission pub const fn radroots_storage::atomic::CommitIngested::new(radroots_storage::event::EventAdmission, core::option::Option<radroots_storage::projection::ProjectionCheckpoint>) -> Self pub const fn radroots_storage::atomic::CommitIngested::projection(&self) -> core::option::Option<&radroots_storage::projection::ProjectionCheckpoint> -pub struct radroots_storage::atomic::CommitSigned -impl radroots_storage::atomic::CommitSigned -pub const fn radroots_storage::atomic::CommitSigned::event(&self) -> &radroots_event::draft::SignedEvent -pub const fn radroots_storage::atomic::CommitSigned::expected_revision(&self) -> radroots_storage::journal::JournalRevision -pub const fn radroots_storage::atomic::CommitSigned::instance_id(&self) -> radroots_storage::journal::OperationInstanceId -pub fn radroots_storage::atomic::CommitSigned::new(radroots_storage::journal::OperationInstanceId, radroots_storage::journal::JournalRevision, radroots_event::draft::SignedEvent) -> Self pub trait radroots_storage::atomic::AtomicStorage: core::marker::Send + core::marker::Sync pub fn radroots_storage::atomic::AtomicStorage::commit(&self, radroots_storage::atomic::AtomicCommit) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::atomic::AtomicCommitReceipt, radroots_storage::Error>> pub fn radroots_storage::atomic::AtomicStorage::receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::atomic::AtomicCommitReceipt>, radroots_storage::Error>> impl radroots_storage::atomic::AtomicStorage for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::commit(&self, radroots_storage::atomic::AtomicCommit) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::atomic::AtomicCommitReceipt, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::atomic::AtomicCommitReceipt>, radroots_storage::Error>> +pub mod radroots_storage::authored +pub enum radroots_storage::authored::AdmissionState +pub radroots_storage::authored::AdmissionState::Cancelled +pub radroots_storage::authored::AdmissionState::Duplicate +pub radroots_storage::authored::AdmissionState::Inserted +pub radroots_storage::authored::AdmissionState::Pending +pub radroots_storage::authored::AdmissionState::Rejected +pub radroots_storage::authored::AdmissionState::Retryable +impl radroots_storage::authored::AdmissionState +pub const fn radroots_storage::authored::AdmissionState::is_admitted(self) -> bool +pub enum radroots_storage::authored::ArtifactOrigin +pub radroots_storage::authored::ArtifactOrigin::ImportedSigned +pub radroots_storage::authored::ArtifactOrigin::Planned +impl radroots_storage::authored::ArtifactOrigin +pub const fn radroots_storage::authored::ArtifactOrigin::is_resignable(self) -> bool +pub enum radroots_storage::authored::FailureClass +pub radroots_storage::authored::FailureClass::Indeterminate +pub radroots_storage::authored::FailureClass::Retryable +pub radroots_storage::authored::FailureClass::Terminal +pub enum radroots_storage::authored::SigningState +pub radroots_storage::authored::SigningState::Cancelled +pub radroots_storage::authored::SigningState::FailedTerminal +pub radroots_storage::authored::SigningState::Indeterminate +pub radroots_storage::authored::SigningState::Planned +pub radroots_storage::authored::SigningState::Retryable +pub radroots_storage::authored::SigningState::Signed +pub enum radroots_storage::authored::WorkPhase +pub radroots_storage::authored::WorkPhase::Admission +pub radroots_storage::authored::WorkPhase::Delivery +pub radroots_storage::authored::WorkPhase::Signing +pub struct radroots_storage::authored::AuthoredArtifact +impl radroots_storage::authored::AuthoredArtifact +pub const fn radroots_storage::authored::AuthoredArtifact::admission_claim(&self) -> core::option::Option<&radroots_storage::authored::WorkClaim> +pub const fn radroots_storage::authored::AuthoredArtifact::admission_retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub const fn radroots_storage::authored::AuthoredArtifact::admission_state(&self) -> radroots_storage::authored::AdmissionState +pub const fn radroots_storage::authored::AuthoredArtifact::artifact_id(&self) -> radroots_storage::authored::AuthoredArtifactId +pub fn radroots_storage::authored::AuthoredArtifact::cancel_signing(&mut self, u64) -> core::result::Result<(), radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredArtifact::created_at_unix_ms(&self) -> u64 +pub fn radroots_storage::authored::AuthoredArtifact::imported_signed(radroots_storage::authored::AuthoredArtifactId, radroots_storage::journal::OperationInstanceId, u16, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredArtifact::last_failure(&self) -> core::option::Option<&radroots_storage::authored::WorkFailure> +pub const fn radroots_storage::authored::AuthoredArtifact::operation_id(&self) -> radroots_storage::journal::OperationInstanceId +pub const fn radroots_storage::authored::AuthoredArtifact::ordinal(&self) -> u16 +pub const fn radroots_storage::authored::AuthoredArtifact::origin(&self) -> radroots_storage::authored::ArtifactOrigin +pub const fn radroots_storage::authored::AuthoredArtifact::plan(&self) -> core::option::Option<&radroots_storage::authored::DurableAuthoredPlan> +pub fn radroots_storage::authored::AuthoredArtifact::planned(radroots_storage::authored::AuthoredArtifactId, radroots_storage::journal::OperationInstanceId, u16, &radroots_event_codec::authoring::AuthoredEventPlan, u64) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::AuthoredArtifact::reconstruct(Self) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::AuthoredArtifact::record_admission(&mut self, radroots_storage::authored::AdmissionState, core::option::Option<radroots_storage::authored::WorkFailure>, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored::AuthoredArtifact::record_signed(&mut self, radroots_event::draft::SignedEvent, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored::AuthoredArtifact::record_signing_failure(&mut self, radroots_storage::authored::WorkFailure, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<(), radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredArtifact::revision(&self) -> core::num::nonzero::NonZeroU64 +pub fn radroots_storage::authored::AuthoredArtifact::set_admission_claim(&mut self, radroots_storage::authored::WorkClaim, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored::AuthoredArtifact::set_signing_claim(&mut self, radroots_storage::authored::WorkClaim, u64) -> core::result::Result<(), radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredArtifact::signed(&self) -> core::option::Option<&radroots_storage::authored::ExactSignedArtifact> +pub const fn radroots_storage::authored::AuthoredArtifact::signing_claim(&self) -> core::option::Option<&radroots_storage::authored::WorkClaim> +pub const fn radroots_storage::authored::AuthoredArtifact::signing_retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub const fn radroots_storage::authored::AuthoredArtifact::signing_state(&self) -> radroots_storage::authored::SigningState +pub const fn radroots_storage::authored::AuthoredArtifact::updated_at_unix_ms(&self) -> u64 +pub fn radroots_storage::authored::AuthoredArtifact::validate(&self) -> core::result::Result<(), radroots_storage::Error> +pub struct radroots_storage::authored::AuthoredArtifactId(_) +impl radroots_storage::authored::AuthoredArtifactId +pub const fn radroots_storage::authored::AuthoredArtifactId::as_bytes(&self) -> &[u8; 16] +pub const fn radroots_storage::authored::AuthoredArtifactId::new([u8; 16]) -> core::result::Result<Self, radroots_storage::Error> +impl core::convert::From<radroots_storage::authored::AuthoredArtifactId> for [u8; 16] +pub fn [u8; 16]::from(radroots_storage::authored::AuthoredArtifactId) -> Self +impl core::convert::TryFrom<[u8; 16]> for radroots_storage::authored::AuthoredArtifactId +pub type radroots_storage::authored::AuthoredArtifactId::Error = radroots_storage::Error +pub fn radroots_storage::authored::AuthoredArtifactId::try_from([u8; 16]) -> core::result::Result<Self, Self::Error> +pub struct radroots_storage::authored::AuthoredOperation +impl radroots_storage::authored::AuthoredOperation +pub fn radroots_storage::authored::AuthoredOperation::artifact_ids(&self) -> &[radroots_storage::authored::AuthoredArtifactId] +pub const fn radroots_storage::authored::AuthoredOperation::created_at_unix_ms(&self) -> u64 +pub fn radroots_storage::authored::AuthoredOperation::new(radroots_storage::journal::OperationInstanceId, alloc::vec::Vec<radroots_storage::authored::AuthoredArtifactId>, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredOperation::operation_id(&self) -> radroots_storage::journal::OperationInstanceId +pub fn radroots_storage::authored::AuthoredOperation::reconstruct(radroots_storage::journal::OperationInstanceId, alloc::vec::Vec<radroots_storage::authored::AuthoredArtifactId>, u64, u64, core::num::nonzero::NonZeroU64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::AuthoredOperation::revision(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored::AuthoredOperation::updated_at_unix_ms(&self) -> u64 +pub struct radroots_storage::authored::DurableAuthoredPlan +impl radroots_storage::authored::DurableAuthoredPlan +pub fn radroots_storage::authored::DurableAuthoredPlan::decode(&self) -> core::result::Result<radroots_event_codec::authoring::wire::HistoricalPlanIntegrity, radroots_storage::Error> +pub fn radroots_storage::authored::DurableAuthoredPlan::from_plan(&radroots_event_codec::authoring::AuthoredEventPlan) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::DurableAuthoredPlan::reconstruct(alloc::vec::Vec<u8>) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::DurableAuthoredPlan::wire_json(&self) -> &[u8] +pub struct radroots_storage::authored::ExactSignedArtifact +impl radroots_storage::authored::ExactSignedArtifact +pub const fn radroots_storage::authored::ExactSignedArtifact::event(&self) -> &radroots_event::draft::SignedEvent +pub fn radroots_storage::authored::ExactSignedArtifact::new(radroots_event::draft::SignedEvent) -> Self +pub const fn radroots_storage::authored::ExactSignedArtifact::raw_json_sha256(&self) -> &[u8; 32] +pub fn radroots_storage::authored::ExactSignedArtifact::reconstruct(radroots_event::draft::SignedEvent, [u8; 32]) -> core::result::Result<Self, radroots_storage::Error> +pub struct radroots_storage::authored::OperationSettlement +impl radroots_storage::authored::OperationSettlement +pub const fn radroots_storage::authored::OperationSettlement::admitted(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::artifacts(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::cancelled(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_cancelled(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_exhausted(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_failed_terminal(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_pending(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_plans(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_retryable(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::delivery_satisfied(self) -> u16 +pub fn radroots_storage::authored::OperationSettlement::evaluate(&radroots_storage::authored::AuthoredOperation, &[radroots_storage::authored::AuthoredArtifact]) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::OperationSettlement::evaluate_complete(&radroots_storage::authored::AuthoredOperation, &[radroots_storage::authored::AuthoredArtifact], &[radroots_storage::authored_delivery::AuthoredDeliveryPlan]) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::OperationSettlement::failed_terminal(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::has_failures(self) -> bool +pub const fn radroots_storage::authored::OperationSettlement::indeterminate(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::is_settled(self) -> bool +pub const fn radroots_storage::authored::OperationSettlement::is_successful(self) -> bool +pub const fn radroots_storage::authored::OperationSettlement::pending(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::retryable(self) -> u16 +pub const fn radroots_storage::authored::OperationSettlement::signed(self) -> u16 +pub struct radroots_storage::authored::RetrySchedule +impl radroots_storage::authored::RetrySchedule +pub const fn radroots_storage::authored::RetrySchedule::attempt(&self) -> core::num::nonzero::NonZeroU32 +pub const fn radroots_storage::authored::RetrySchedule::failure(&self) -> &radroots_storage::authored::WorkFailure +pub fn radroots_storage::authored::RetrySchedule::new(core::num::nonzero::NonZeroU32, u64, radroots_storage::authored::WorkFailure) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::RetrySchedule::next_attempt(&self, u64, radroots_storage::authored::WorkFailure) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::RetrySchedule::not_before_unix_ms(&self) -> u64 +pub struct radroots_storage::authored::WorkClaim +impl radroots_storage::authored::WorkClaim +pub const fn radroots_storage::authored::WorkClaim::acquired_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored::WorkClaim::expires_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored::WorkClaim::generation(&self) -> core::num::nonzero::NonZeroU64 +pub fn radroots_storage::authored::WorkClaim::matches_fence(&self, &[u8; 16], core::num::nonzero::NonZeroU64, core::num::nonzero::NonZeroU64, u64) -> bool +pub fn radroots_storage::authored::WorkClaim::new([u8; 16], impl core::convert::Into<alloc::string::String>, core::num::nonzero::NonZeroU64, u64, u64, core::num::nonzero::NonZeroU64) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored::WorkClaim::owner(&self) -> &str +pub const fn radroots_storage::authored::WorkClaim::row_revision(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored::WorkClaim::token(&self) -> &[u8; 16] +pub fn radroots_storage::authored::WorkClaim::validate(&self) -> core::result::Result<(), radroots_storage::Error> +pub struct radroots_storage::authored::WorkFailure +impl radroots_storage::authored::WorkFailure +pub const fn radroots_storage::authored::WorkFailure::class(&self) -> radroots_storage::authored::FailureClass +pub fn radroots_storage::authored::WorkFailure::code(&self) -> &str +pub fn radroots_storage::authored::WorkFailure::diagnostic(&self) -> core::option::Option<&str> +pub fn radroots_storage::authored::WorkFailure::new(impl core::convert::Into<alloc::string::String>, radroots_storage::authored::WorkPhase, radroots_storage::authored::FailureClass, core::option::Option<u64>, core::option::Option<alloc::string::String>) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored::WorkFailure::phase(&self) -> radroots_storage::authored::WorkPhase +pub const fn radroots_storage::authored::WorkFailure::retry_after_unix_ms(&self) -> core::option::Option<u64> +pub fn radroots_storage::authored::WorkFailure::validate(&self) -> core::result::Result<(), radroots_storage::Error> +pub const radroots_storage::authored::AUTHORED_OPERATION_ARTIFACTS_MAX: usize +pub const radroots_storage::authored::WORK_CLAIM_OWNER_MAX_BYTES: usize +pub const radroots_storage::authored::WORK_FAILURE_CODE_MAX_BYTES: usize +pub const radroots_storage::authored::WORK_FAILURE_DIAGNOSTIC_MAX_BYTES: usize +pub mod radroots_storage::authored_atomic +pub enum radroots_storage::authored_atomic::AuthoredAtomicCommand +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::ApplyAdmission(radroots_storage::authored_atomic::ApplyAdmissionResult) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::ApplyDelivery(radroots_storage::authored_atomic::ApplyDeliveryAttempt) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::ApplyFailure(radroots_storage::authored_atomic::ApplyWorkFailure) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::ApplySigned(radroots_storage::authored_atomic::ApplySignedArtifact) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::Cancel(radroots_storage::authored_atomic::CancelAuthoredWork) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::Claim(radroots_storage::authored_atomic::ClaimAuthoredWork) +pub radroots_storage::authored_atomic::AuthoredAtomicCommand::Prepare(radroots_storage::authored_atomic::PrepareAuthoredOperation) +impl radroots_storage::authored_atomic::AuthoredAtomicCommand +pub fn radroots_storage::authored_atomic::AuthoredAtomicCommand::commit_id(&self) -> radroots_storage::atomic::AtomicCommitId +pub fn radroots_storage::authored_atomic::AuthoredAtomicCommand::digest(&self) -> radroots_storage::atomic::AtomicCommitDigest +pub const fn radroots_storage::authored_atomic::AuthoredAtomicCommand::requested_at_unix_ms(&self) -> u64 +pub enum radroots_storage::authored_atomic::AuthoredAtomicOutcome +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::Artifact(radroots_storage::authored::AuthoredArtifact) +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::DeliveryPlan(radroots_storage::authored_delivery::AuthoredDeliveryPlan) +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared::artifacts: alloc::vec::Vec<radroots_storage::authored::AuthoredArtifact> +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared::delivery_plans: alloc::vec::Vec<radroots_storage::authored_delivery::AuthoredDeliveryPlan> +pub radroots_storage::authored_atomic::AuthoredAtomicOutcome::Prepared::operation: radroots_storage::authored::AuthoredOperation +pub enum radroots_storage::authored_atomic::AuthoredWorkTarget +pub radroots_storage::authored_atomic::AuthoredWorkTarget::Artifact(radroots_storage::authored::AuthoredArtifactId) +pub radroots_storage::authored_atomic::AuthoredWorkTarget::DeliveryPlan(radroots_storage::authored_delivery::AuthoredDeliveryPlanId) +pub enum radroots_storage::authored_atomic::CancelAuthoredTarget +pub radroots_storage::authored_atomic::CancelAuthoredTarget::ArtifactAdmission(radroots_storage::authored::AuthoredArtifactId) +pub radroots_storage::authored_atomic::CancelAuthoredTarget::ArtifactSigning(radroots_storage::authored::AuthoredArtifactId) +pub radroots_storage::authored_atomic::CancelAuthoredTarget::DeliveryPlan(radroots_storage::authored_delivery::AuthoredDeliveryPlanId) +pub enum radroots_storage::authored_atomic::ClaimAuthoredTarget +pub radroots_storage::authored_atomic::ClaimAuthoredTarget::ArtifactAdmission(radroots_storage::authored::AuthoredArtifactId) +pub radroots_storage::authored_atomic::ClaimAuthoredTarget::ArtifactSigning(radroots_storage::authored::AuthoredArtifactId) +pub radroots_storage::authored_atomic::ClaimAuthoredTarget::DeliveryPlan(radroots_storage::authored_delivery::AuthoredDeliveryPlanId) +pub struct radroots_storage::authored_atomic::ApplyAdmissionResult +impl radroots_storage::authored_atomic::ApplyAdmissionResult +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::applied_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::artifact_id(&self) -> radroots_storage::authored::AuthoredArtifactId +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::failure(&self) -> core::option::Option<&radroots_storage::authored::WorkFailure> +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::fence(&self) -> &radroots_storage::authored_atomic::WorkFence +pub fn radroots_storage::authored_atomic::ApplyAdmissionResult::new(radroots_storage::authored::AuthoredArtifactId, radroots_storage::authored_atomic::WorkFence, radroots_storage::authored::AdmissionState, core::option::Option<radroots_storage::authored::WorkFailure>, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub const fn radroots_storage::authored_atomic::ApplyAdmissionResult::state(&self) -> radroots_storage::authored::AdmissionState +pub struct radroots_storage::authored_atomic::ApplyDeliveryAttempt +impl radroots_storage::authored_atomic::ApplyDeliveryAttempt +pub const fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::applied_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::fence(&self) -> &radroots_storage::authored_atomic::WorkFence +pub fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::new(radroots_storage::authored_delivery::AuthoredDeliveryPlanId, radroots_storage::authored_atomic::WorkFence, radroots_storage::authored_delivery::DeliveryAttemptOutcome, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::outcome(&self) -> &radroots_storage::authored_delivery::DeliveryAttemptOutcome +pub const fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::plan_id(&self) -> radroots_storage::authored_delivery::AuthoredDeliveryPlanId +pub const fn radroots_storage::authored_atomic::ApplyDeliveryAttempt::retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub struct radroots_storage::authored_atomic::ApplySignedArtifact +impl radroots_storage::authored_atomic::ApplySignedArtifact +pub const fn radroots_storage::authored_atomic::ApplySignedArtifact::applied_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::ApplySignedArtifact::artifact_id(&self) -> radroots_storage::authored::AuthoredArtifactId +pub const fn radroots_storage::authored_atomic::ApplySignedArtifact::event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_storage::authored_atomic::ApplySignedArtifact::fence(&self) -> &radroots_storage::authored_atomic::WorkFence +pub fn radroots_storage::authored_atomic::ApplySignedArtifact::new(radroots_storage::authored::AuthoredArtifactId, radroots_storage::authored_atomic::WorkFence, radroots_event::draft::SignedEvent, u64) -> core::result::Result<Self, radroots_storage::Error> +pub struct radroots_storage::authored_atomic::ApplyWorkFailure +impl radroots_storage::authored_atomic::ApplyWorkFailure +pub const fn radroots_storage::authored_atomic::ApplyWorkFailure::applied_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::ApplyWorkFailure::failure(&self) -> &radroots_storage::authored::WorkFailure +pub const fn radroots_storage::authored_atomic::ApplyWorkFailure::fence(&self) -> &radroots_storage::authored_atomic::WorkFence +pub fn radroots_storage::authored_atomic::ApplyWorkFailure::new(radroots_storage::authored_atomic::AuthoredWorkTarget, radroots_storage::authored_atomic::WorkFence, radroots_storage::authored::WorkFailure, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::ApplyWorkFailure::retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub const fn radroots_storage::authored_atomic::ApplyWorkFailure::target(&self) -> &radroots_storage::authored_atomic::AuthoredWorkTarget +pub struct radroots_storage::authored_atomic::AuthoredAtomicReceipt +impl radroots_storage::authored_atomic::AuthoredAtomicReceipt +pub const fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::commit_id(&self) -> radroots_storage::atomic::AtomicCommitId +pub const fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::committed_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::digest(&self) -> radroots_storage::atomic::AtomicCommitDigest +pub const fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::disposition(&self) -> radroots_storage::atomic::AtomicCommitDisposition +pub fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::from_durable_parts(radroots_storage::atomic::AtomicCommitId, radroots_storage::atomic::AtomicCommitDigest, radroots_storage::atomic::AtomicCommitDisposition, u64, radroots_storage::authored_atomic::AuthoredAtomicOutcome) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::new(&radroots_storage::authored_atomic::AuthoredAtomicCommand, radroots_storage::atomic::AtomicCommitDisposition, u64, radroots_storage::authored_atomic::AuthoredAtomicOutcome) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::AuthoredAtomicReceipt::outcome(&self) -> &radroots_storage::authored_atomic::AuthoredAtomicOutcome +pub struct radroots_storage::authored_atomic::CancelAuthoredWork +impl radroots_storage::authored_atomic::CancelAuthoredWork +pub const fn radroots_storage::authored_atomic::CancelAuthoredWork::cancelled_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_atomic::CancelAuthoredWork::expected_revision(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored_atomic::CancelAuthoredWork::new(radroots_storage::authored_atomic::CancelAuthoredTarget, core::num::nonzero::NonZeroU64, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::CancelAuthoredWork::target(&self) -> &radroots_storage::authored_atomic::CancelAuthoredTarget +pub struct radroots_storage::authored_atomic::ClaimAuthoredWork +impl radroots_storage::authored_atomic::ClaimAuthoredWork +pub const fn radroots_storage::authored_atomic::ClaimAuthoredWork::claim(&self) -> &radroots_storage::authored::WorkClaim +pub const fn radroots_storage::authored_atomic::ClaimAuthoredWork::new(radroots_storage::authored_atomic::ClaimAuthoredTarget, radroots_storage::authored::WorkClaim) -> Self +pub const fn radroots_storage::authored_atomic::ClaimAuthoredWork::target(&self) -> &radroots_storage::authored_atomic::ClaimAuthoredTarget +pub struct radroots_storage::authored_atomic::PrepareAuthoredOperation +impl radroots_storage::authored_atomic::PrepareAuthoredOperation +pub fn radroots_storage::authored_atomic::PrepareAuthoredOperation::artifacts(&self) -> &[radroots_storage::authored::AuthoredArtifact] +pub fn radroots_storage::authored_atomic::PrepareAuthoredOperation::delivery_plans(&self) -> &[radroots_storage::authored_delivery::AuthoredDeliveryPlan] +pub const fn radroots_storage::authored_atomic::PrepareAuthoredOperation::input_digest(&self) -> radroots_storage::atomic::AtomicCommitDigest +pub fn radroots_storage::authored_atomic::PrepareAuthoredOperation::new(radroots_storage::authored::AuthoredOperation, alloc::vec::Vec<radroots_storage::authored::AuthoredArtifact>, alloc::vec::Vec<radroots_storage::authored_delivery::AuthoredDeliveryPlan>, radroots_storage::atomic::AtomicCommitDigest, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::PrepareAuthoredOperation::operation(&self) -> &radroots_storage::authored::AuthoredOperation +pub const fn radroots_storage::authored_atomic::PrepareAuthoredOperation::requested_at_unix_ms(&self) -> u64 +pub struct radroots_storage::authored_atomic::WorkFence +impl radroots_storage::authored_atomic::WorkFence +pub const fn radroots_storage::authored_atomic::WorkFence::generation(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored_atomic::WorkFence::new([u8; 16], core::num::nonzero::NonZeroU64, core::num::nonzero::NonZeroU64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_atomic::WorkFence::row_revision(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored_atomic::WorkFence::token(&self) -> &[u8; 16] +pub trait radroots_storage::authored_atomic::AuthoredAtomicStorage: core::marker::Send + core::marker::Sync +pub fn radroots_storage::authored_atomic::AuthoredAtomicStorage::authored_artifact(&self, radroots_storage::authored::AuthoredArtifactId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredArtifact>, radroots_storage::Error>> +pub fn radroots_storage::authored_atomic::AuthoredAtomicStorage::authored_delivery_plan(&self, radroots_storage::authored_delivery::AuthoredDeliveryPlanId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_delivery::AuthoredDeliveryPlan>, radroots_storage::Error>> +pub fn radroots_storage::authored_atomic::AuthoredAtomicStorage::authored_operation(&self, radroots_storage::journal::OperationInstanceId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredOperation>, radroots_storage::Error>> +pub fn radroots_storage::authored_atomic::AuthoredAtomicStorage::authored_receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_atomic::AuthoredAtomicReceipt>, radroots_storage::Error>> +pub fn radroots_storage::authored_atomic::AuthoredAtomicStorage::execute_authored(&self, radroots_storage::authored_atomic::AuthoredAtomicCommand) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::authored_atomic::AuthoredAtomicReceipt, radroots_storage::Error>> +impl radroots_storage::authored_atomic::AuthoredAtomicStorage for radroots_storage::memory::MemoryStorage +pub fn radroots_storage::memory::MemoryStorage::authored_artifact(&self, radroots_storage::authored::AuthoredArtifactId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredArtifact>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_delivery_plan(&self, radroots_storage::authored_delivery::AuthoredDeliveryPlanId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_delivery::AuthoredDeliveryPlan>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_operation(&self, radroots_storage::journal::OperationInstanceId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredOperation>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_atomic::AuthoredAtomicReceipt>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::execute_authored(&self, radroots_storage::authored_atomic::AuthoredAtomicCommand) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::authored_atomic::AuthoredAtomicReceipt, radroots_storage::Error>> +pub mod radroots_storage::authored_delivery +pub enum radroots_storage::authored_delivery::AuthoredDeliveryState +pub radroots_storage::authored_delivery::AuthoredDeliveryState::Cancelled +pub radroots_storage::authored_delivery::AuthoredDeliveryState::Exhausted +pub radroots_storage::authored_delivery::AuthoredDeliveryState::FailedTerminal +pub radroots_storage::authored_delivery::AuthoredDeliveryState::Pending +pub radroots_storage::authored_delivery::AuthoredDeliveryState::Retryable +pub radroots_storage::authored_delivery::AuthoredDeliveryState::Satisfied +impl radroots_storage::authored_delivery::AuthoredDeliveryState +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryState::is_terminal(self) -> bool +pub enum radroots_storage::authored_delivery::DeliveryAttemptOutcome +pub radroots_storage::authored_delivery::DeliveryAttemptOutcome::Receipt(radroots_transport::sink::DeliveryReceipt) +pub radroots_storage::authored_delivery::DeliveryAttemptOutcome::SinkFailure(radroots_transport::sink::SinkFailure) +pub struct radroots_storage::authored_delivery::AuthoredDeliveryAttempt +impl radroots_storage::authored_delivery::AuthoredDeliveryAttempt +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryAttempt::attempt(&self) -> core::num::nonzero::NonZeroU32 +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryAttempt::outcome(&self) -> &radroots_storage::authored_delivery::DeliveryAttemptOutcome +pub fn radroots_storage::authored_delivery::AuthoredDeliveryAttempt::reconstruct(core::num::nonzero::NonZeroU32, u64, radroots_storage::authored_delivery::DeliveryAttemptOutcome, radroots_transport::policy::SatisfactionState) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryAttempt::recorded_at_unix_ms(&self) -> u64 +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryAttempt::satisfaction(&self) -> radroots_transport::policy::SatisfactionState +pub struct radroots_storage::authored_delivery::AuthoredDeliveryIntent +impl radroots_storage::authored_delivery::AuthoredDeliveryIntent +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::deadline_unix_ms(&self) -> u64 +pub fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::from_request(&radroots_transport::sink::DeliveryRequest) -> Self +pub fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::materialize(&self, radroots_transport::sink::DeliveryPayload) -> core::result::Result<radroots_transport::sink::DeliveryRequest, radroots_storage::Error> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::new(impl core::convert::Into<alloc::string::String>, radroots_transport::target::TargetSet, radroots_transport::policy::SatisfactionPolicy, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::request_id(&self) -> &radroots_transport::sink::DeliveryRequestId +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::satisfaction(&self) -> &radroots_transport::policy::SatisfactionPolicy +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryIntent::target_set(&self) -> &radroots_transport::target::TargetSet +pub struct radroots_storage::authored_delivery::AuthoredDeliveryPlan +impl radroots_storage::authored_delivery::AuthoredDeliveryPlan +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::apply_receipt(&mut self, &[u8; 16], core::num::nonzero::NonZeroU64, core::num::nonzero::NonZeroU64, radroots_transport::sink::DeliveryReceipt, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::apply_sink_failure(&mut self, &[u8; 16], core::num::nonzero::NonZeroU64, core::num::nonzero::NonZeroU64, radroots_transport::sink::SinkFailure, core::option::Option<radroots_storage::authored::RetrySchedule>, u64) -> core::result::Result<(), radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::artifact_id(&self) -> radroots_storage::authored::AuthoredArtifactId +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::attempt_count(&self) -> u32 +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::attempts(&self) -> &[radroots_storage::authored_delivery::AuthoredDeliveryAttempt] +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::bind_signed_event(&mut self, radroots_event::draft::SignedEvent, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::cancel(&mut self, u64) -> core::result::Result<(), radroots_storage::Error> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::claim(&mut self, radroots_storage::authored::WorkClaim, u64) -> core::result::Result<(), radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::claim_evidence(&self) -> core::option::Option<&radroots_storage::authored::WorkClaim> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::created_at_unix_ms(&self) -> u64 +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::evaluate_next_attempt(&self, &radroots_storage::authored_delivery::DeliveryAttemptOutcome) -> core::result::Result<radroots_transport::policy::SatisfactionState, radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::intent(&self) -> &radroots_storage::authored_delivery::AuthoredDeliveryIntent +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::last_failure(&self) -> core::option::Option<&radroots_storage::authored::WorkFailure> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::new(radroots_storage::authored_delivery::AuthoredDeliveryPlanId, radroots_storage::authored::AuthoredArtifactId, radroots_storage::authored_delivery::AuthoredDeliveryIntent, u64) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::new_bound(radroots_storage::authored_delivery::AuthoredDeliveryPlanId, radroots_storage::authored::AuthoredArtifactId, radroots_transport::sink::DeliveryRequest, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::plan_id(&self) -> radroots_storage::authored_delivery::AuthoredDeliveryPlanId +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::reconstruct(Self) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::request(&self) -> core::option::Option<&radroots_transport::sink::DeliveryRequest> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::request_digest(&self) -> &[u8; 32] +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::retry(&self) -> core::option::Option<&radroots_storage::authored::RetrySchedule> +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::revision(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::state(&self) -> radroots_storage::authored_delivery::AuthoredDeliveryState +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::updated_at_unix_ms(&self) -> u64 +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlan::validate(&self) -> core::result::Result<(), radroots_storage::Error> +pub struct radroots_storage::authored_delivery::AuthoredDeliveryPlanId(_) +impl radroots_storage::authored_delivery::AuthoredDeliveryPlanId +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlanId::as_bytes(&self) -> &[u8; 16] +pub const fn radroots_storage::authored_delivery::AuthoredDeliveryPlanId::new([u8; 16]) -> core::result::Result<Self, radroots_storage::Error> +impl core::convert::From<radroots_storage::authored_delivery::AuthoredDeliveryPlanId> for [u8; 16] +pub fn [u8; 16]::from(radroots_storage::authored_delivery::AuthoredDeliveryPlanId) -> Self +impl core::convert::TryFrom<[u8; 16]> for radroots_storage::authored_delivery::AuthoredDeliveryPlanId +pub type radroots_storage::authored_delivery::AuthoredDeliveryPlanId::Error = radroots_storage::Error +pub fn radroots_storage::authored_delivery::AuthoredDeliveryPlanId::try_from([u8; 16]) -> core::result::Result<Self, Self::Error> +pub const radroots_storage::authored_delivery::DELIVERY_PLAN_ATTEMPTS_MAX: u32 pub mod radroots_storage::backup pub enum radroots_storage::backup::BackupMemberKind pub radroots_storage::backup::BackupMemberKind::Metadata @@ -124,10 +407,14 @@ impl radroots_storage::backup::BackupFormatVersion pub const radroots_storage::backup::BackupFormatVersion::V1: Self pub const fn radroots_storage::backup::BackupFormatVersion::get(self) -> u16 pub const fn radroots_storage::backup::BackupFormatVersion::new(u16) -> core::result::Result<Self, radroots_storage::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::BackupFormatVersion +pub fn radroots_storage::backup::BackupFormatVersion::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::BackupId(_) impl radroots_storage::backup::BackupId pub const fn radroots_storage::backup::BackupId::as_bytes(&self) -> &[u8; 16] pub const fn radroots_storage::backup::BackupId::new([u8; 16]) -> core::result::Result<Self, radroots_storage::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::BackupId +pub fn radroots_storage::backup::BackupId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::BackupManifest impl radroots_storage::backup::BackupManifest pub const fn radroots_storage::backup::BackupManifest::backup_id(&self) -> radroots_storage::backup::BackupId @@ -138,6 +425,8 @@ pub fn radroots_storage::backup::BackupManifest::members(&self) -> &[radroots_st pub fn radroots_storage::backup::BackupManifest::new(radroots_storage::backup::BackupFormatVersion, radroots_storage::backup::BackupId, u64, radroots_storage::backup::BackupSecretPolicy, alloc::vec::Vec<radroots_storage::backup::BackupMember>) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::backup::BackupManifest::secret_policy(&self) -> radroots_storage::backup::BackupSecretPolicy pub const fn radroots_storage::backup::BackupManifest::total_bytes(&self) -> u64 +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::BackupManifest +pub fn radroots_storage::backup::BackupManifest::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::BackupMember impl radroots_storage::backup::BackupMember pub const fn radroots_storage::backup::BackupMember::byte_length(&self) -> u64 @@ -145,6 +434,8 @@ pub const fn radroots_storage::backup::BackupMember::kind(&self) -> radroots_sto pub fn radroots_storage::backup::BackupMember::new(impl core::convert::Into<alloc::string::String>, radroots_storage::backup::BackupMemberKind, u64, radroots_storage::backup::MemberDigest) -> core::result::Result<Self, radroots_storage::Error> pub fn radroots_storage::backup::BackupMember::relative_path(&self) -> &str pub const fn radroots_storage::backup::BackupMember::sha256(&self) -> radroots_storage::backup::MemberDigest +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::BackupMember +pub fn radroots_storage::backup::BackupMember::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::BackupOperation impl radroots_storage::backup::BackupOperation pub const fn radroots_storage::backup::BackupOperation::manifest(&self) -> core::option::Option<&radroots_storage::backup::BackupManifest> @@ -161,6 +452,8 @@ pub const fn radroots_storage::backup::BackupPlan::format_version(&self) -> radr pub const fn radroots_storage::backup::BackupPlan::new(radroots_storage::backup::BackupId, radroots_storage::backup::BackupFormatVersion, radroots_storage::backup::BackupSecretPolicy, u64) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::backup::BackupPlan::requested_at_unix_ms(&self) -> u64 pub const fn radroots_storage::backup::BackupPlan::secret_policy(&self) -> radroots_storage::backup::BackupSecretPolicy +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::BackupPlan +pub fn radroots_storage::backup::BackupPlan::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::MemberDigest(_) impl radroots_storage::backup::MemberDigest pub const fn radroots_storage::backup::MemberDigest::as_bytes(&self) -> &[u8; 32] @@ -170,11 +463,15 @@ impl radroots_storage::backup::ReliabilityRevision pub const radroots_storage::backup::ReliabilityRevision::INITIAL: Self pub const fn radroots_storage::backup::ReliabilityRevision::get(self) -> u64 pub const fn radroots_storage::backup::ReliabilityRevision::new(u64) -> core::result::Result<Self, radroots_storage::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::ReliabilityRevision +pub fn radroots_storage::backup::ReliabilityRevision::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::RestoreMemberStatus impl radroots_storage::backup::RestoreMemberStatus pub fn radroots_storage::backup::RestoreMemberStatus::new(impl core::convert::Into<alloc::string::String>, radroots_storage::backup::MemberVerification) -> core::result::Result<Self, radroots_storage::Error> pub fn radroots_storage::backup::RestoreMemberStatus::relative_path(&self) -> &str pub const fn radroots_storage::backup::RestoreMemberStatus::verification(&self) -> radroots_storage::backup::MemberVerification +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::RestoreMemberStatus +pub fn radroots_storage::backup::RestoreMemberStatus::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_storage::backup::RestoreOperation impl radroots_storage::backup::RestoreOperation pub fn radroots_storage::backup::RestoreOperation::member_status(&self) -> &[radroots_storage::backup::RestoreMemberStatus] @@ -189,6 +486,8 @@ pub const fn radroots_storage::backup::RestorePlan::accepted_secret_policy(&self pub const fn radroots_storage::backup::RestorePlan::manifest(&self) -> &radroots_storage::backup::BackupManifest pub fn radroots_storage::backup::RestorePlan::new(radroots_storage::backup::BackupManifest, radroots_storage::backup::BackupSecretPolicy, u64) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::backup::RestorePlan::requested_at_unix_ms(&self) -> u64 +impl<'de> serde_core::de::Deserialize<'de> for radroots_storage::backup::RestorePlan +pub fn radroots_storage::backup::RestorePlan::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub const radroots_storage::backup::BACKUP_MEMBER_MAX: usize pub const radroots_storage::backup::BACKUP_MEMBER_PATH_MAX_BYTES: usize pub trait radroots_storage::backup::StorageReliability: core::marker::Send + core::marker::Sync @@ -208,6 +507,8 @@ pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transp pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> pub mod radroots_storage::event +pub use radroots_storage::event::BoxFuture +pub use radroots_storage::event::EventId pub enum radroots_storage::event::AdmissionDisposition pub radroots_storage::event::AdmissionDisposition::Advanced pub radroots_storage::event::AdmissionDisposition::Duplicate @@ -268,6 +569,7 @@ pub const fn radroots_storage::event::SourceGeneration::as_bytes(&self) -> &[u8; pub const fn radroots_storage::event::SourceGeneration::new([u8; 32]) -> core::result::Result<Self, radroots_storage::Error> pub struct radroots_storage::event::StoredEventProvenance impl radroots_storage::event::StoredEventProvenance +pub fn radroots_storage::event::StoredEventProvenance::from_stored_parts(radroots_storage::event::EventPosition, &str, &str, u64, core::option::Option<&str>) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::event::StoredEventProvenance::new(radroots_storage::event::EventPosition, radroots_transport::source::EventProvenance) -> Self pub const fn radroots_storage::event::StoredEventProvenance::position(&self) -> radroots_storage::event::EventPosition pub const fn radroots_storage::event::StoredEventProvenance::provenance(&self) -> &radroots_transport::source::EventProvenance @@ -279,13 +581,13 @@ pub const fn radroots_storage::event::StoredRawEvent::position(&self) -> radroot pub const fn radroots_storage::event::StoredRawEvent::stage(&self) -> radroots_storage::event::AdmissionStage pub struct radroots_storage::event::StoredVerifiedEvent impl radroots_storage::event::StoredVerifiedEvent -pub const fn radroots_storage::event::StoredVerifiedEvent::event(&self) -> &radroots_event::verification::SignatureVerifiedEvent -pub const fn radroots_storage::event::StoredVerifiedEvent::new(radroots_storage::event::EventPosition, radroots_event::verification::SignatureVerifiedEvent) -> Self +pub const fn radroots_storage::event::StoredVerifiedEvent::event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_storage::event::StoredVerifiedEvent::new(radroots_storage::event::EventPosition, radroots_event::draft::SignedEvent) -> Self pub const fn radroots_storage::event::StoredVerifiedEvent::position(&self) -> radroots_storage::event::EventPosition pub struct radroots_storage::event::StoredVisibleEvent impl radroots_storage::event::StoredVisibleEvent -pub const fn radroots_storage::event::StoredVisibleEvent::event(&self) -> &radroots_event::admission::VisibleEvent -pub const fn radroots_storage::event::StoredVisibleEvent::new(radroots_storage::event::EventPosition, radroots_event::admission::VisibleEvent) -> Self +pub const fn radroots_storage::event::StoredVisibleEvent::event(&self) -> &radroots_event::draft::SignedEvent +pub const fn radroots_storage::event::StoredVisibleEvent::new(radroots_storage::event::EventPosition, radroots_event::draft::SignedEvent) -> Self pub const fn radroots_storage::event::StoredVisibleEvent::position(&self) -> radroots_storage::event::EventPosition pub const radroots_storage::event::EVENT_QUERY_ID_MAX: usize pub const radroots_storage::event::EVENT_QUERY_LIMIT_MAX: u16 @@ -305,6 +607,9 @@ pub fn radroots_storage::memory::MemoryStorage::query_visible(&self, radroots_st pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::EventStoreStatus, radroots_storage::Error>> pub type radroots_storage::event::EventCursor = radroots_storage::event::EventPosition pub mod radroots_storage::journal +pub use radroots_storage::journal::BoxFuture +pub use radroots_storage::journal::EventId +pub use radroots_storage::journal::OperationId pub enum radroots_storage::journal::CancellationState pub radroots_storage::journal::CancellationState::CancelledBeforeCommit pub radroots_storage::journal::CancellationState::NotRequested @@ -425,6 +730,12 @@ pub fn radroots_storage::memory::MemoryStorage::default() -> Self impl radroots_storage::atomic::AtomicStorage for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::commit(&self, radroots_storage::atomic::AtomicCommit) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::atomic::AtomicCommitReceipt, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::atomic::AtomicCommitReceipt>, radroots_storage::Error>> +impl radroots_storage::authored_atomic::AuthoredAtomicStorage for radroots_storage::memory::MemoryStorage +pub fn radroots_storage::memory::MemoryStorage::authored_artifact(&self, radroots_storage::authored::AuthoredArtifactId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredArtifact>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_delivery_plan(&self, radroots_storage::authored_delivery::AuthoredDeliveryPlanId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_delivery::AuthoredDeliveryPlan>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_operation(&self, radroots_storage::journal::OperationInstanceId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored::AuthoredOperation>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::authored_receipt(&self, radroots_storage::atomic::AtomicCommitId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::authored_atomic::AuthoredAtomicReceipt>, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::execute_authored(&self, radroots_storage::authored_atomic::AuthoredAtomicCommand) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::authored_atomic::AuthoredAtomicReceipt, radroots_storage::Error>> impl radroots_storage::backup::StorageReliability for radroots_storage::memory::MemoryStorage pub fn radroots_storage::memory::MemoryStorage::begin_backup(&self, radroots_storage::backup::BackupPlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::begin_restore(&self, radroots_storage::backup::RestorePlan) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -465,12 +776,36 @@ pub fn radroots_storage::memory::MemoryStorage::checkpoint(&self, radroots_stora pub fn radroots_storage::memory::MemoryStorage::event_index_checkpoint(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::event_index_manifest(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::invalidate(&self, radroots_storage::projection::ProjectionInvalidation) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::invalidation(&self, radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionInvalidation>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_checkpoint(&self, radroots_storage::projection::EventIndexCheckpoint) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_manifest(&self, radroots_storage::projection::EventIndexManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::rebuild(&self, radroots_storage::projection::RebuildTicketId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::request_rebuild(&self, radroots_storage::projection::RebuildTicket) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self, radroots_storage::projection::ProjectionId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_rebuild(&self, radroots_storage::projection::RebuildTransition) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> +impl radroots_storage::status::StorageStatusProvider for radroots_storage::memory::MemoryStorage +pub fn radroots_storage::memory::MemoryStorage::storage_status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub mod radroots_storage::outbox +pub use radroots_storage::outbox::BoxFuture +pub use radroots_storage::outbox::DeliveryOutcome +pub use radroots_storage::outbox::DeliveryOutcomeKind +pub use radroots_storage::outbox::DeliveryPayload +pub use radroots_storage::outbox::DeliveryReceipt +pub use radroots_storage::outbox::DeliveryRequest +pub use radroots_storage::outbox::DeliveryTargetReceipt +pub use radroots_storage::outbox::Retryability +pub use radroots_storage::outbox::SatisfactionClass +pub use radroots_storage::outbox::SatisfactionPolicy +pub use radroots_storage::outbox::SatisfactionState +pub use radroots_storage::outbox::TARGET_SET_MAX_ITEMS +pub use radroots_storage::outbox::Target +pub use radroots_storage::outbox::TargetFingerprint +pub use radroots_storage::outbox::TargetLabel +pub use radroots_storage::outbox::TargetPolicy +pub use radroots_storage::outbox::TargetScope +pub use radroots_storage::outbox::TargetSet +pub use radroots_storage::outbox::TransportId +pub use radroots_storage::outbox::evaluate_transport_satisfaction pub enum radroots_storage::outbox::EnqueueDisposition pub radroots_storage::outbox::EnqueueDisposition::Created pub radroots_storage::outbox::EnqueueDisposition::Replay @@ -507,8 +842,13 @@ pub const fn radroots_storage::outbox::DeliveryAttempt::get(self) -> u32 pub const fn radroots_storage::outbox::DeliveryAttempt::new(u32) -> core::result::Result<Self, radroots_storage::Error> pub struct radroots_storage::outbox::DeliveryAttemptEvidence impl radroots_storage::outbox::DeliveryAttemptEvidence +pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::attempt(&self) -> radroots_storage::outbox::DeliveryAttempt +pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::expected_revision(&self) -> radroots_storage::outbox::OutboxRevision pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::item_id(&self) -> radroots_storage::outbox::OutboxItemId +pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::lease_id(&self) -> radroots_storage::outbox::LeaseId pub fn radroots_storage::outbox::DeliveryAttemptEvidence::new(radroots_storage::outbox::OutboxItemId, radroots_storage::outbox::LeaseId, radroots_storage::outbox::OutboxRevision, radroots_storage::outbox::DeliveryAttempt, radroots_transport::sink::DeliveryReceipt, u64) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::receipt(&self) -> &radroots_transport::sink::DeliveryReceipt +pub const fn radroots_storage::outbox::DeliveryAttemptEvidence::recorded_at_unix_ms(&self) -> u64 pub struct radroots_storage::outbox::DeliveryPlanDigest(_) impl radroots_storage::outbox::DeliveryPlanDigest pub const fn radroots_storage::outbox::DeliveryPlanDigest::as_bytes(&self) -> &[u8; 32] @@ -558,6 +898,7 @@ impl radroots_storage::outbox::OutboxRecord pub fn radroots_storage::outbox::OutboxRecord::claim(&mut self, radroots_storage::outbox::OutboxLease) -> core::result::Result<(), radroots_storage::Error> pub const fn radroots_storage::outbox::OutboxRecord::created_at_unix_ms(&self) -> u64 pub fn radroots_storage::outbox::OutboxRecord::evidence(&self) -> &[radroots_storage::outbox::TargetDeliveryEvidence] +pub fn radroots_storage::outbox::OutboxRecord::from_durable_parts(radroots_storage::outbox::EnqueueOutboxItem, radroots_storage::outbox::OutboxRevision, radroots_storage::outbox::OutboxStage, core::option::Option<radroots_storage::outbox::OutboxLease>, core::option::Option<radroots_storage::outbox::DeliveryAttempt>, alloc::vec::Vec<radroots_storage::outbox::TargetDeliveryEvidence>, radroots_storage::outbox::SatisfactionResult, core::option::Option<u64>, u64) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::outbox::OutboxRecord::item_id(&self) -> radroots_storage::outbox::OutboxItemId pub const fn radroots_storage::outbox::OutboxRecord::last_attempt(&self) -> core::option::Option<radroots_storage::outbox::DeliveryAttempt> pub fn radroots_storage::outbox::OutboxRecord::latest_target_evidence(&self, &radroots_transport::target::TargetFingerprint) -> core::option::Option<&radroots_storage::outbox::TargetDeliveryEvidence> @@ -588,6 +929,7 @@ pub fn radroots_storage::outbox::OutboxStatus::total(self) -> core::option::Opti pub struct radroots_storage::outbox::TargetDeliveryEvidence impl radroots_storage::outbox::TargetDeliveryEvidence pub const fn radroots_storage::outbox::TargetDeliveryEvidence::attempt(&self) -> radroots_storage::outbox::DeliveryAttempt +pub fn radroots_storage::outbox::TargetDeliveryEvidence::new(radroots_transport::target::TargetFingerprint, radroots_storage::outbox::DeliveryAttempt, bool, radroots_transport::outcome::DeliveryOutcome, u64) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::outbox::TargetDeliveryEvidence::outcome(&self) -> &radroots_transport::outcome::DeliveryOutcome pub const fn radroots_storage::outbox::TargetDeliveryEvidence::recorded_at_unix_ms(&self) -> u64 pub const fn radroots_storage::outbox::TargetDeliveryEvidence::target(&self) -> &radroots_transport::target::TargetFingerprint @@ -657,6 +999,7 @@ impl radroots_storage::private_artifact::PrivateArtifactMetadata pub const fn radroots_storage::private_artifact::PrivateArtifactMetadata::artifact_id(&self) -> radroots_storage::private_artifact::PrivateArtifactId pub const fn radroots_storage::private_artifact::PrivateArtifactMetadata::commitment(&self) -> radroots_storage::private_artifact::ArtifactCommitment pub const fn radroots_storage::private_artifact::PrivateArtifactMetadata::created_at_unix_ms(&self) -> u64 +pub fn radroots_storage::private_artifact::PrivateArtifactMetadata::from_durable_parts(radroots_storage::private_artifact::PrivateArtifactId, radroots_storage::private_artifact::ArtifactKind, radroots_storage::private_artifact::ArtifactSchemaId, radroots_storage::private_artifact::ArtifactCommitment, u64, radroots_storage::private_artifact::DurableSecretReference, radroots_storage::private_artifact::RetentionPolicy, radroots_storage::private_artifact::PrivateArtifactRevision, radroots_storage::private_artifact::PrivateArtifactStage, u64, u64, core::option::Option<(u64, radroots_storage::private_artifact::DeletionReason, radroots_storage::private_artifact::ArtifactCommitment)>) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::private_artifact::PrivateArtifactMetadata::kind(&self) -> &radroots_storage::private_artifact::ArtifactKind pub fn radroots_storage::private_artifact::PrivateArtifactMetadata::mark_expired(&self, radroots_storage::private_artifact::PrivateArtifactRevision, u64) -> core::result::Result<Self, radroots_storage::Error> pub fn radroots_storage::private_artifact::PrivateArtifactMetadata::new(radroots_storage::private_artifact::PrivateArtifactId, radroots_storage::private_artifact::ArtifactKind, radroots_storage::private_artifact::ArtifactSchemaId, radroots_storage::private_artifact::ArtifactCommitment, u64, radroots_storage::private_artifact::DurableSecretReference, radroots_storage::private_artifact::RetentionPolicy, u64) -> core::result::Result<Self, radroots_storage::Error> @@ -708,6 +1051,8 @@ pub fn radroots_storage::memory::MemoryStorage::put_metadata(&self, radroots_sto pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::private_artifact::PrivateArtifactStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::tombstone(&self, radroots_storage::private_artifact::PrivateArtifactId, radroots_storage::private_artifact::PrivateArtifactRevision, u64, radroots_storage::private_artifact::DeletionReason) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::private_artifact::PrivateArtifactMetadata, radroots_storage::Error>> pub mod radroots_storage::projection +pub use radroots_storage::projection::BoxFuture +pub use radroots_storage::projection::EventId pub enum radroots_storage::projection::InvalidationReason pub radroots_storage::projection::InvalidationReason::EventIndexManifestChanged pub radroots_storage::projection::InvalidationReason::IntegrityFailure @@ -719,6 +1064,11 @@ pub radroots_storage::projection::ProjectionHealth::Failed pub radroots_storage::projection::ProjectionHealth::Invalidated pub radroots_storage::projection::ProjectionHealth::Ready pub radroots_storage::projection::ProjectionHealth::Rebuilding +pub enum radroots_storage::projection::RebuildFailure +pub radroots_storage::projection::RebuildFailure::IntegrityFailure +pub radroots_storage::projection::RebuildFailure::PromotionRejected +pub radroots_storage::projection::RebuildFailure::ReducerRejected +pub radroots_storage::projection::RebuildFailure::SourceChanged pub enum radroots_storage::projection::RebuildStage pub radroots_storage::projection::RebuildStage::Completed pub radroots_storage::projection::RebuildStage::Failed @@ -808,13 +1158,22 @@ pub const fn radroots_storage::projection::ProjectionStatus::generation(&self) - pub const fn radroots_storage::projection::ProjectionStatus::health(&self) -> radroots_storage::projection::ProjectionHealth pub fn radroots_storage::projection::ProjectionStatus::new(radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration, radroots_storage::projection::ProjectionHealth, core::option::Option<radroots_storage::projection::ProjectionCheckpoint>, core::option::Option<radroots_storage::projection::RebuildTicketId>) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::projection::ProjectionStatus::projection_id(&self) -> &radroots_storage::projection::ProjectionId +pub struct radroots_storage::projection::RawSourceDigest(_) +impl radroots_storage::projection::RawSourceDigest +pub const fn radroots_storage::projection::RawSourceDigest::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_storage::projection::RawSourceDigest::new([u8; 32]) -> Self pub struct radroots_storage::projection::RebuildTicket impl radroots_storage::projection::RebuildTicket pub const fn radroots_storage::projection::RebuildTicket::checkpoint(&self) -> core::option::Option<&radroots_storage::projection::ProjectionCheckpoint> +pub const fn radroots_storage::projection::RebuildTicket::failure(&self) -> core::option::Option<radroots_storage::projection::RebuildFailure> +pub fn radroots_storage::projection::RebuildTicket::from_durable_parts(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionInvalidation, radroots_storage::projection::ProjectionRevision, radroots_storage::projection::RebuildStage, radroots_storage::event::SourceGeneration, core::option::Option<radroots_storage::event::EventPosition>, radroots_storage::projection::RawSourceDigest, core::option::Option<radroots_storage::projection::ProjectionCheckpoint>, core::option::Option<radroots_storage::projection::RebuildFailure>, u64, u64) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::projection::RebuildTicket::invalidation(&self) -> &radroots_storage::projection::ProjectionInvalidation -pub fn radroots_storage::projection::RebuildTicket::requested(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionInvalidation) -> Self +pub fn radroots_storage::projection::RebuildTicket::requested(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionInvalidation, radroots_storage::event::SourceGeneration, core::option::Option<radroots_storage::event::EventPosition>, radroots_storage::projection::RawSourceDigest) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::projection::RebuildTicket::requested_at_unix_ms(&self) -> u64 pub const fn radroots_storage::projection::RebuildTicket::revision(&self) -> radroots_storage::projection::ProjectionRevision +pub const fn radroots_storage::projection::RebuildTicket::source_digest(&self) -> radroots_storage::projection::RawSourceDigest +pub const fn radroots_storage::projection::RebuildTicket::source_generation(&self) -> radroots_storage::event::SourceGeneration +pub const fn radroots_storage::projection::RebuildTicket::source_high_water(&self) -> core::option::Option<radroots_storage::event::EventPosition> pub const fn radroots_storage::projection::RebuildTicket::stage(&self) -> radroots_storage::projection::RebuildStage pub const fn radroots_storage::projection::RebuildTicket::ticket_id(&self) -> radroots_storage::projection::RebuildTicketId pub fn radroots_storage::projection::RebuildTicket::transition(&self, radroots_storage::projection::RebuildTransition) -> core::result::Result<Self, radroots_storage::Error> @@ -827,7 +1186,7 @@ pub struct radroots_storage::projection::RebuildTransition impl radroots_storage::projection::RebuildTransition pub const fn radroots_storage::projection::RebuildTransition::checkpoint(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionRevision, u64, radroots_storage::projection::ProjectionCheckpoint) -> Self pub const fn radroots_storage::projection::RebuildTransition::complete(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionRevision, u64, radroots_storage::projection::ProjectionCheckpoint) -> Self -pub const fn radroots_storage::projection::RebuildTransition::fail(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionRevision, u64) -> Self +pub const fn radroots_storage::projection::RebuildTransition::fail(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionRevision, u64, radroots_storage::projection::RebuildFailure) -> Self pub const fn radroots_storage::projection::RebuildTransition::start(radroots_storage::projection::RebuildTicketId, radroots_storage::projection::ProjectionRevision, u64) -> Self pub const fn radroots_storage::projection::RebuildTransition::ticket_id(&self) -> radroots_storage::projection::RebuildTicketId pub const radroots_storage::projection::EVENT_INDEX_ARTIFACT_PATH_MAX_BYTES: usize @@ -840,8 +1199,10 @@ pub fn radroots_storage::projection::ProjectionStore::checkpoint(&self, radroots pub fn radroots_storage::projection::ProjectionStore::event_index_checkpoint(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::event_index_manifest(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::invalidate(&self, radroots_storage::projection::ProjectionInvalidation) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>> +pub fn radroots_storage::projection::ProjectionStore::invalidation(&self, radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionInvalidation>, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::put_event_index_checkpoint(&self, radroots_storage::projection::EventIndexCheckpoint) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::put_event_index_manifest(&self, radroots_storage::projection::EventIndexManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> +pub fn radroots_storage::projection::ProjectionStore::rebuild(&self, radroots_storage::projection::RebuildTicketId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::request_rebuild(&self, radroots_storage::projection::RebuildTicket) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::status(&self, radroots_storage::projection::ProjectionId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>> pub fn radroots_storage::projection::ProjectionStore::transition_rebuild(&self, radroots_storage::projection::RebuildTransition) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> @@ -850,8 +1211,10 @@ pub fn radroots_storage::memory::MemoryStorage::checkpoint(&self, radroots_stora pub fn radroots_storage::memory::MemoryStorage::event_index_checkpoint(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::event_index_manifest(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::invalidate(&self, radroots_storage::projection::ProjectionInvalidation) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::invalidation(&self, radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionInvalidation>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_checkpoint(&self, radroots_storage::projection::EventIndexCheckpoint) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_manifest(&self, radroots_storage::projection::EventIndexManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::rebuild(&self, radroots_storage::projection::RebuildTicketId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::request_rebuild(&self, radroots_storage::projection::RebuildTicket) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self, radroots_storage::projection::ProjectionId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_rebuild(&self, radroots_storage::projection::RebuildTransition) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> @@ -908,6 +1271,10 @@ pub const fn radroots_storage::status::StorageStatus::open_mode(self) -> radroot pub const fn radroots_storage::status::StorageStatus::shutdown(self) -> radroots_storage::status::ShutdownState pub const fn radroots_storage::status::StorageStatus::wal_enabled(self) -> bool pub const fn radroots_storage::status::StorageStatus::writer_policy(self) -> radroots_storage::status::WriterPolicy +pub trait radroots_storage::status::StorageStatusProvider: core::marker::Send + core::marker::Sync +pub fn radroots_storage::status::StorageStatusProvider::storage_status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> +impl radroots_storage::status::StorageStatusProvider for radroots_storage::memory::MemoryStorage +pub fn radroots_storage::memory::MemoryStorage::storage_status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> #[non_exhaustive] pub enum radroots_storage::Error pub radroots_storage::Error::AdmissionEventMismatch pub radroots_storage::Error::AdmissionRegression @@ -924,6 +1291,8 @@ pub radroots_storage::Error::CorruptProjectionRecord pub radroots_storage::Error::CorruptReliabilityOperation pub radroots_storage::Error::CorruptStoredEvent pub radroots_storage::Error::CursorGenerationMismatch +pub radroots_storage::Error::DeliveryAttemptOverflow +pub radroots_storage::Error::DeliveryPlanClaimConflict pub radroots_storage::Error::DuplicateBackupMember pub radroots_storage::Error::DuplicateEventIndexShard pub radroots_storage::Error::DuplicateEventQueryId @@ -934,6 +1303,10 @@ pub radroots_storage::Error::EventPageLimitExceeded pub radroots_storage::Error::IdempotencyConflict pub radroots_storage::Error::InvalidAtomicCommitId pub radroots_storage::Error::InvalidAtomicCommitTimestamp +pub radroots_storage::Error::InvalidAuthoredArtifact +pub radroots_storage::Error::InvalidAuthoredDeliveryPlan +pub radroots_storage::Error::InvalidAuthoredOperation +pub radroots_storage::Error::InvalidAuthoredTransition pub radroots_storage::Error::InvalidBackupId pub radroots_storage::Error::InvalidBackupManifest pub radroots_storage::Error::InvalidBackupMemberLength @@ -987,8 +1360,11 @@ pub radroots_storage::Error::InvalidRecoveryDeadline pub radroots_storage::Error::InvalidReliabilityRevision pub radroots_storage::Error::InvalidRestoreTimestamp pub radroots_storage::Error::InvalidRestoreTransition +pub radroots_storage::Error::InvalidRetrySchedule pub radroots_storage::Error::InvalidSourceGeneration pub radroots_storage::Error::InvalidStorageStatus +pub radroots_storage::Error::InvalidWorkClaim +pub radroots_storage::Error::InvalidWorkFailure pub radroots_storage::Error::JournalOperationCommitted pub radroots_storage::Error::JournalRevisionConflict pub radroots_storage::Error::OperationIdentityMismatch @@ -1089,8 +1465,10 @@ pub fn radroots_storage::ProjectionStore::checkpoint(&self, radroots_storage::pr pub fn radroots_storage::ProjectionStore::event_index_checkpoint(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::event_index_manifest(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::invalidate(&self, radroots_storage::projection::ProjectionInvalidation) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>> +pub fn radroots_storage::ProjectionStore::invalidation(&self, radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionInvalidation>, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::put_event_index_checkpoint(&self, radroots_storage::projection::EventIndexCheckpoint) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::put_event_index_manifest(&self, radroots_storage::projection::EventIndexManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> +pub fn radroots_storage::ProjectionStore::rebuild(&self, radroots_storage::projection::RebuildTicketId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::request_rebuild(&self, radroots_storage::projection::RebuildTicket) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::status(&self, radroots_storage::projection::ProjectionId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>> pub fn radroots_storage::ProjectionStore::transition_rebuild(&self, radroots_storage::projection::RebuildTransition) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> @@ -1099,10 +1477,12 @@ pub fn radroots_storage::memory::MemoryStorage::checkpoint(&self, radroots_stora pub fn radroots_storage::memory::MemoryStorage::event_index_checkpoint(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexCheckpoint>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::event_index_manifest(&self, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::EventIndexManifest>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::invalidate(&self, radroots_storage::projection::ProjectionInvalidation) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::ProjectionStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::invalidation(&self, radroots_storage::projection::ProjectionId, radroots_storage::projection::ProjectionGeneration) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionInvalidation>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_checkpoint(&self, radroots_storage::projection::EventIndexCheckpoint) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::put_event_index_manifest(&self, radroots_storage::projection::EventIndexManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::rebuild(&self, radroots_storage::projection::RebuildTicketId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::RebuildTicket>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::request_rebuild(&self, radroots_storage::projection::RebuildTicket) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::status(&self, radroots_storage::projection::ProjectionId) -> radroots_transport::source::BoxFuture<'_, core::result::Result<core::option::Option<radroots_storage::projection::ProjectionStatus>, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_rebuild(&self, radroots_storage::projection::RebuildTransition) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::projection::RebuildTicket, radroots_storage::Error>> -pub trait radroots_storage::Storage: radroots_storage::event::EventStore + radroots_storage::journal::Journal + radroots_storage::outbox::Outbox + radroots_storage::projection::ProjectionStore + radroots_storage::private_artifact::PrivateArtifactStore + radroots_storage::backup::StorageReliability + radroots_storage::atomic::AtomicStorage -impl<T> radroots_storage::Storage for T where T: radroots_storage::event::EventStore + radroots_storage::journal::Journal + radroots_storage::outbox::Outbox + radroots_storage::projection::ProjectionStore + radroots_storage::private_artifact::PrivateArtifactStore + radroots_storage::backup::StorageReliability + radroots_storage::atomic::AtomicStorage +pub trait radroots_storage::Storage: radroots_storage::event::EventStore + radroots_storage::journal::Journal + radroots_storage::outbox::Outbox + radroots_storage::projection::ProjectionStore + radroots_storage::private_artifact::PrivateArtifactStore + radroots_storage::backup::StorageReliability + radroots_storage::atomic::AtomicStorage + radroots_storage::authored_atomic::AuthoredAtomicStorage +impl<T> radroots_storage::Storage for T where T: radroots_storage::event::EventStore + radroots_storage::journal::Journal + radroots_storage::outbox::Outbox + radroots_storage::projection::ProjectionStore + radroots_storage::private_artifact::PrivateArtifactStore + radroots_storage::backup::StorageReliability + radroots_storage::atomic::AtomicStorage + radroots_storage::authored_atomic::AuthoredAtomicStorage diff --git a/docs/api/radroots_transport.txt b/docs/api/radroots_transport.txt @@ -40,48 +40,48 @@ pub mod radroots_transport::endpoint pub struct radroots_transport::endpoint::EndpointUri(_) impl radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::as_str(&self) -> &str -pub fn radroots_transport::target::EndpointUri::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::EndpointUri::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::EndpointUri -pub type radroots_transport::target::EndpointUri::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::EndpointUri::Error = radroots_transport::error::Error pub fn radroots_transport::target::EndpointUri::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::EndpointUri -pub type radroots_transport::target::EndpointUri::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::EndpointUri::Err = radroots_transport::error::Error pub fn radroots_transport::target::EndpointUri::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::endpoint::TargetLabel(_) impl radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::as_str(&self) -> &str -pub fn radroots_transport::target::TargetLabel::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetLabel::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::TargetLabel -pub type radroots_transport::target::TargetLabel::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetLabel::Error = radroots_transport::error::Error pub fn radroots_transport::target::TargetLabel::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::TargetLabel -pub type radroots_transport::target::TargetLabel::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetLabel::Err = radroots_transport::error::Error pub fn radroots_transport::target::TargetLabel::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::endpoint::TargetScope(_) impl radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::as_str(&self) -> &str -pub fn radroots_transport::target::TargetScope::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetScope::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::TargetScope -pub type radroots_transport::target::TargetScope::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetScope::Error = radroots_transport::error::Error pub fn radroots_transport::target::TargetScope::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::TargetScope -pub type radroots_transport::target::TargetScope::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetScope::Err = radroots_transport::error::Error pub fn radroots_transport::target::TargetScope::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> @@ -89,65 +89,64 @@ pub const radroots_transport::endpoint::ENDPOINT_URI_MAX_BYTES: usize pub const radroots_transport::endpoint::TARGET_LABEL_MAX_BYTES: usize pub const radroots_transport::endpoint::TARGET_SCOPE_MAX_BYTES: usize pub mod radroots_transport::error -pub enum radroots_transport::error::RadrootsTransportError -pub radroots_transport::error::RadrootsTransportError::DeliveryReceiptRequestIdMismatch -pub radroots_transport::error::RadrootsTransportError::DeliveryReceiptTargetSetMismatch -pub radroots_transport::error::RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch -pub radroots_transport::error::RadrootsTransportError::DeliveryTargetReceiptStatusMismatch -pub radroots_transport::error::RadrootsTransportError::DuplicateDeliveryTargetReceipt -pub radroots_transport::error::RadrootsTransportError::DuplicateFetchAuthor -pub radroots_transport::error::RadrootsTransportError::DuplicateFetchKind -pub radroots_transport::error::RadrootsTransportError::DuplicateFetchTargetOutcome -pub radroots_transport::error::RadrootsTransportError::DuplicateRequiredTargetFingerprint -pub radroots_transport::error::RadrootsTransportError::DuplicateTargetFingerprint -pub radroots_transport::error::RadrootsTransportError::EmptyDeliveryRequestId -pub radroots_transport::error::RadrootsTransportError::EmptyFetchCursor -pub radroots_transport::error::RadrootsTransportError::EmptyFetchRequestId -pub radroots_transport::error::RadrootsTransportError::EmptyPayloadBytes -pub radroots_transport::error::RadrootsTransportError::EmptyPayloadId -pub radroots_transport::error::RadrootsTransportError::EmptyPayloadLabel -pub radroots_transport::error::RadrootsTransportError::EmptyRequiredTargetSet -pub radroots_transport::error::RadrootsTransportError::EmptyTargetLabel -pub radroots_transport::error::RadrootsTransportError::EmptyTargetScope -pub radroots_transport::error::RadrootsTransportError::EmptyTargetSet -pub radroots_transport::error::RadrootsTransportError::EmptyTargetUri -pub radroots_transport::error::RadrootsTransportError::EmptyTransportKind -pub radroots_transport::error::RadrootsTransportError::FetchPageLimitExceeded -pub radroots_transport::error::RadrootsTransportError::FetchPageRequestMismatch -pub radroots_transport::error::RadrootsTransportError::FetchSelectorTooLarge -pub radroots_transport::error::RadrootsTransportError::InvalidDeliveryDeadline -pub radroots_transport::error::RadrootsTransportError::InvalidDeliveryOutcome -pub radroots_transport::error::RadrootsTransportError::InvalidDeliveryRequestId -pub radroots_transport::error::RadrootsTransportError::InvalidDeliveryTimestamp -pub radroots_transport::error::RadrootsTransportError::InvalidFetchCursor -pub radroots_transport::error::RadrootsTransportError::InvalidFetchDeadline -pub radroots_transport::error::RadrootsTransportError::InvalidFetchLimit -pub radroots_transport::error::RadrootsTransportError::InvalidFetchRequestId -pub radroots_transport::error::RadrootsTransportError::InvalidFetchTimeRange -pub radroots_transport::error::RadrootsTransportError::InvalidObservedAt -pub radroots_transport::error::RadrootsTransportError::InvalidPayloadBytes -pub radroots_transport::error::RadrootsTransportError::InvalidPayloadDigest -pub radroots_transport::error::RadrootsTransportError::InvalidPayloadId -pub radroots_transport::error::RadrootsTransportError::InvalidPayloadLabel -pub radroots_transport::error::RadrootsTransportError::InvalidSatisfactionPolicy -pub radroots_transport::error::RadrootsTransportError::InvalidTargetFingerprint -pub radroots_transport::error::RadrootsTransportError::InvalidTargetLabel -pub radroots_transport::error::RadrootsTransportError::InvalidTargetScope -pub radroots_transport::error::RadrootsTransportError::InvalidTargetUri -pub radroots_transport::error::RadrootsTransportError::InvalidTransportKind -pub radroots_transport::error::RadrootsTransportError::MissingDeliveryTargetReceipt -pub radroots_transport::error::RadrootsTransportError::PayloadDigestMismatch -pub radroots_transport::error::RadrootsTransportError::RequiredTargetNotRequested -pub radroots_transport::error::RadrootsTransportError::TargetSetTooLarge -pub radroots_transport::error::RadrootsTransportError::TransportOutcomeStatusMismatch -pub radroots_transport::error::RadrootsTransportError::UnexpectedDeliveryTargetReceipt -pub radroots_transport::error::RadrootsTransportError::UnexpectedFetchEvent -pub radroots_transport::error::RadrootsTransportError::UnexpectedFetchProvenance -pub radroots_transport::error::RadrootsTransportError::UnexpectedFetchTargetOutcome -pub radroots_transport::error::RadrootsTransportError::UnsupportedOperation -impl core::fmt::Display for radroots_transport::error::RadrootsTransportError -pub fn radroots_transport::error::RadrootsTransportError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -pub type radroots_transport::error::Error = radroots_transport::error::RadrootsTransportError +pub enum radroots_transport::error::Error +pub radroots_transport::error::Error::DeliveryReceiptRequestIdMismatch +pub radroots_transport::error::Error::DeliveryReceiptTargetSetMismatch +pub radroots_transport::error::Error::DeliveryTargetReceiptAttemptMismatch +pub radroots_transport::error::Error::DeliveryTargetReceiptStatusMismatch +pub radroots_transport::error::Error::DuplicateDeliveryTargetReceipt +pub radroots_transport::error::Error::DuplicateFetchAuthor +pub radroots_transport::error::Error::DuplicateFetchKind +pub radroots_transport::error::Error::DuplicateFetchTargetOutcome +pub radroots_transport::error::Error::DuplicateRequiredTargetFingerprint +pub radroots_transport::error::Error::DuplicateTargetFingerprint +pub radroots_transport::error::Error::EmptyDeliveryRequestId +pub radroots_transport::error::Error::EmptyFetchCursor +pub radroots_transport::error::Error::EmptyFetchRequestId +pub radroots_transport::error::Error::EmptyPayloadBytes +pub radroots_transport::error::Error::EmptyPayloadId +pub radroots_transport::error::Error::EmptyPayloadLabel +pub radroots_transport::error::Error::EmptyRequiredTargetSet +pub radroots_transport::error::Error::EmptyTargetLabel +pub radroots_transport::error::Error::EmptyTargetScope +pub radroots_transport::error::Error::EmptyTargetSet +pub radroots_transport::error::Error::EmptyTargetUri +pub radroots_transport::error::Error::EmptyTransportKind +pub radroots_transport::error::Error::FetchPageLimitExceeded +pub radroots_transport::error::Error::FetchPageRequestMismatch +pub radroots_transport::error::Error::FetchSelectorTooLarge +pub radroots_transport::error::Error::InvalidDeliveryDeadline +pub radroots_transport::error::Error::InvalidDeliveryOutcome +pub radroots_transport::error::Error::InvalidDeliveryRequestId +pub radroots_transport::error::Error::InvalidDeliveryTimestamp +pub radroots_transport::error::Error::InvalidFetchCursor +pub radroots_transport::error::Error::InvalidFetchDeadline +pub radroots_transport::error::Error::InvalidFetchLimit +pub radroots_transport::error::Error::InvalidFetchRequestId +pub radroots_transport::error::Error::InvalidFetchTimeRange +pub radroots_transport::error::Error::InvalidObservedAt +pub radroots_transport::error::Error::InvalidPayloadBytes +pub radroots_transport::error::Error::InvalidPayloadDigest +pub radroots_transport::error::Error::InvalidPayloadId +pub radroots_transport::error::Error::InvalidPayloadLabel +pub radroots_transport::error::Error::InvalidSatisfactionPolicy +pub radroots_transport::error::Error::InvalidTargetFingerprint +pub radroots_transport::error::Error::InvalidTargetLabel +pub radroots_transport::error::Error::InvalidTargetScope +pub radroots_transport::error::Error::InvalidTargetUri +pub radroots_transport::error::Error::InvalidTransportKind +pub radroots_transport::error::Error::MissingDeliveryTargetReceipt +pub radroots_transport::error::Error::PayloadDigestMismatch +pub radroots_transport::error::Error::RequiredTargetNotRequested +pub radroots_transport::error::Error::TargetSetTooLarge +pub radroots_transport::error::Error::TransportOutcomeStatusMismatch +pub radroots_transport::error::Error::UnexpectedDeliveryTargetReceipt +pub radroots_transport::error::Error::UnexpectedFetchEvent +pub radroots_transport::error::Error::UnexpectedFetchProvenance +pub radroots_transport::error::Error::UnexpectedFetchTargetOutcome +pub radroots_transport::error::Error::UnsupportedOperation +impl core::fmt::Display for radroots_transport::error::Error +pub fn radroots_transport::error::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_transport::outcome pub enum radroots_transport::outcome::DeliveryOutcomeKind pub radroots_transport::outcome::DeliveryOutcomeKind::Accepted @@ -199,6 +198,10 @@ pub mod radroots_transport::policy pub enum radroots_transport::policy::SatisfactionClass pub radroots_transport::policy::SatisfactionClass::Accepted pub radroots_transport::policy::SatisfactionClass::Delivered +pub enum radroots_transport::policy::SatisfactionState +pub radroots_transport::policy::SatisfactionState::Exhausted +pub radroots_transport::policy::SatisfactionState::Pending +pub radroots_transport::policy::SatisfactionState::Satisfied pub struct radroots_transport::policy::SatisfactionPolicy impl radroots_transport::policy::SatisfactionPolicy pub const fn radroots_transport::policy::SatisfactionPolicy::class(&self) -> radroots_transport::policy::SatisfactionClass @@ -217,6 +220,7 @@ pub fn radroots_transport::policy::TargetPolicy::required(alloc::vec::Vec<radroo pub fn radroots_transport::policy::TargetPolicy::required_targets(&self) -> core::option::Option<&[radroots_transport::target::TargetFingerprint]> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::policy::TargetPolicy pub fn radroots_transport::policy::TargetPolicy::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub fn radroots_transport::policy::evaluate_satisfaction<'a, I>(&radroots_transport::policy::SatisfactionPolicy, &radroots_transport::target::TargetSet, I) -> core::result::Result<radroots_transport::policy::SatisfactionState, radroots_transport::error::Error> where I: core::iter::traits::collect::IntoIterator<Item = (&'a radroots_transport::target::TargetFingerprint, &'a radroots_transport::outcome::DeliveryOutcome)> pub mod radroots_transport::sink pub struct radroots_transport::sink::DeliveryPayload impl radroots_transport::sink::DeliveryPayload @@ -227,6 +231,7 @@ impl radroots_transport::sink::DeliveryReceipt pub fn radroots_transport::sink::DeliveryReceipt::for_request(&radroots_transport::sink::DeliveryRequest, alloc::vec::Vec<radroots_transport::sink::DeliveryTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::sink::DeliveryReceipt::is_satisfied(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<bool, radroots_transport::error::Error> pub const fn radroots_transport::sink::DeliveryReceipt::request_id(&self) -> &radroots_transport::sink::DeliveryRequestId +pub fn radroots_transport::sink::DeliveryReceipt::satisfaction(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<radroots_transport::policy::SatisfactionState, radroots_transport::error::Error> pub fn radroots_transport::sink::DeliveryReceipt::target_receipts(&self) -> &[radroots_transport::sink::DeliveryTargetReceipt] pub fn radroots_transport::sink::DeliveryReceipt::validate_for_request(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<(), radroots_transport::error::Error> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::sink::DeliveryReceipt @@ -258,6 +263,18 @@ pub const fn radroots_transport::sink::DeliveryTargetReceipt::target(&self) -> & pub const fn radroots_transport::sink::DeliveryTargetReceipt::was_attempted(&self) -> bool impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::sink::DeliveryTargetReceipt pub fn radroots_transport::sink::DeliveryTargetReceipt::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_transport::sink::SinkFailure +impl radroots_transport::sink::SinkFailure +pub fn radroots_transport::sink::SinkFailure::code(&self) -> &str +pub fn radroots_transport::sink::SinkFailure::for_request(&radroots_transport::sink::DeliveryRequest, impl core::convert::Into<alloc::string::String>, radroots_transport::outcome::Retryability, core::option::Option<u64>, core::option::Option<alloc::string::String>, alloc::vec::Vec<radroots_transport::sink::DeliveryTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::sink::SinkFailure::invalid_contract(&radroots_transport::sink::DeliveryRequest) -> Self +pub fn radroots_transport::sink::SinkFailure::message(&self) -> core::option::Option<&str> +pub fn radroots_transport::sink::SinkFailure::partial_evidence(&self) -> &[radroots_transport::sink::DeliveryTargetReceipt] +pub const fn radroots_transport::sink::SinkFailure::retry_after_unix_ms(&self) -> core::option::Option<u64> +pub const fn radroots_transport::sink::SinkFailure::retryability(&self) -> radroots_transport::outcome::Retryability +pub fn radroots_transport::sink::SinkFailure::validate_for_request(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<(), radroots_transport::error::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::sink::SinkFailure +pub fn radroots_transport::sink::SinkFailure::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::sink::SinkStatus impl radroots_transport::SinkStatus pub const fn radroots_transport::SinkStatus::availability(&self) -> radroots_transport::capability::Availability @@ -270,7 +287,7 @@ pub fn radroots_transport::SinkStatus::new(radroots_transport::TransportId, bool pub const fn radroots_transport::SinkStatus::transport_id(&self) -> radroots_transport::TransportId pub const radroots_transport::sink::DELIVERY_REQUEST_ID_MAX_BYTES: usize pub trait radroots_transport::sink::EventSink: core::marker::Send + core::marker::Sync -pub fn radroots_transport::sink::EventSink::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::error::Error>> +pub fn radroots_transport::sink::EventSink::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::sink::SinkFailure>> pub fn radroots_transport::sink::EventSink::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::SinkStatus, radroots_transport::error::Error>> pub mod radroots_transport::source pub enum radroots_transport::source::NextPage @@ -380,16 +397,16 @@ pub mod radroots_transport::target pub struct radroots_transport::target::EndpointUri(_) impl radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::as_str(&self) -> &str -pub fn radroots_transport::target::EndpointUri::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::EndpointUri::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::EndpointUri -pub type radroots_transport::target::EndpointUri::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::EndpointUri::Error = radroots_transport::error::Error pub fn radroots_transport::target::EndpointUri::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::EndpointUri -pub type radroots_transport::target::EndpointUri::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::EndpointUri::Err = radroots_transport::error::Error pub fn radroots_transport::target::EndpointUri::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> @@ -398,12 +415,12 @@ impl radroots_transport::target::Target pub fn radroots_transport::target::Target::fingerprint(&self) -> &radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::Target::kind(&self) -> &radroots_transport::TransportId pub fn radroots_transport::target::Target::label(&self) -> core::option::Option<&radroots_transport::target::TargetLabel> -pub fn radroots_transport::target::Target::local(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::local_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::Target::local(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::local_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::scope(&self) -> core::option::Option<&radroots_transport::target::TargetScope> pub fn radroots_transport::target::Target::uri(&self) -> &radroots_transport::target::EndpointUri impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::Target @@ -412,236 +429,125 @@ pub struct radroots_transport::target::TargetFingerprint(_) impl radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::TargetFingerprint::as_str(&self) -> &str pub fn radroots_transport::target::TargetFingerprint::from_target(&radroots_transport::TransportId, &radroots_transport::target::EndpointUri, core::option::Option<&radroots_transport::target::TargetScope>) -> Self -pub fn radroots_transport::target::TargetFingerprint::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetFingerprint::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::TargetFingerprint::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::TargetFingerprint -pub type radroots_transport::target::TargetFingerprint::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetFingerprint::Error = radroots_transport::error::Error pub fn radroots_transport::target::TargetFingerprint::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::TargetFingerprint::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::TargetFingerprint -pub type radroots_transport::target::TargetFingerprint::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetFingerprint::Err = radroots_transport::error::Error pub fn radroots_transport::target::TargetFingerprint::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::TargetFingerprint::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::target::TargetLabel(_) impl radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::as_str(&self) -> &str -pub fn radroots_transport::target::TargetLabel::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetLabel::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::TargetLabel -pub type radroots_transport::target::TargetLabel::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetLabel::Error = radroots_transport::error::Error pub fn radroots_transport::target::TargetLabel::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::TargetLabel -pub type radroots_transport::target::TargetLabel::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetLabel::Err = radroots_transport::error::Error pub fn radroots_transport::target::TargetLabel::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetLabel pub fn radroots_transport::target::TargetLabel::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::target::TargetScope(_) impl radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::as_str(&self) -> &str -pub fn radroots_transport::target::TargetScope::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetScope::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::as_ref(&self) -> &str impl core::convert::TryFrom<&str> for radroots_transport::target::TargetScope -pub type radroots_transport::target::TargetScope::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetScope::Error = radroots_transport::error::Error pub fn radroots_transport::target::TargetScope::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::target::TargetScope -pub type radroots_transport::target::TargetScope::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::target::TargetScope::Err = radroots_transport::error::Error pub fn radroots_transport::target::TargetScope::from_str(&str) -> core::result::Result<Self, Self::Err> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetScope pub fn radroots_transport::target::TargetScope::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::target::TargetSet impl radroots_transport::target::TargetSet +pub fn radroots_transport::target::TargetSet::contains(&self, &radroots_transport::target::TargetFingerprint) -> bool pub fn radroots_transport::target::TargetSet::is_empty(&self) -> bool pub fn radroots_transport::target::TargetSet::len(&self) -> usize -pub fn radroots_transport::target::TargetSet::new(alloc::vec::Vec<radroots_transport::target::Target>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetSet::new(alloc::vec::Vec<radroots_transport::target::Target>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::TargetSet::targets(&self) -> &[radroots_transport::target::Target] impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetSet pub fn radroots_transport::target::TargetSet::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub const radroots_transport::target::TARGET_SET_MAX_ITEMS: usize -pub enum radroots_transport::RadrootsTransportDeliveryTargetStatus -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::Delivered -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedRetryable -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedTerminal -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::Forwarded -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::Pending -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::Seen -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied -pub radroots_transport::RadrootsTransportDeliveryTargetStatus::StoredByGateway -impl radroots_transport::RadrootsTransportDeliveryTargetStatus -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::counts_as_accepted_satisfaction(self) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::counts_as_delivered_satisfaction(self) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::counts_as_satisfied(self, radroots_transport::RadrootsTransportSatisfactionClass) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::is_deferred_until_implemented(self) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::is_ready_for_attempt(self) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::is_retryable_failure(self) -> bool -pub fn radroots_transport::RadrootsTransportDeliveryTargetStatus::is_terminal_failure(self) -> bool -pub enum radroots_transport::RadrootsTransportError -pub radroots_transport::RadrootsTransportError::DeliveryReceiptRequestIdMismatch -pub radroots_transport::RadrootsTransportError::DeliveryReceiptTargetSetMismatch -pub radroots_transport::RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch -pub radroots_transport::RadrootsTransportError::DeliveryTargetReceiptStatusMismatch -pub radroots_transport::RadrootsTransportError::DuplicateDeliveryTargetReceipt -pub radroots_transport::RadrootsTransportError::DuplicateFetchAuthor -pub radroots_transport::RadrootsTransportError::DuplicateFetchKind -pub radroots_transport::RadrootsTransportError::DuplicateFetchTargetOutcome -pub radroots_transport::RadrootsTransportError::DuplicateRequiredTargetFingerprint -pub radroots_transport::RadrootsTransportError::DuplicateTargetFingerprint -pub radroots_transport::RadrootsTransportError::EmptyDeliveryRequestId -pub radroots_transport::RadrootsTransportError::EmptyFetchCursor -pub radroots_transport::RadrootsTransportError::EmptyFetchRequestId -pub radroots_transport::RadrootsTransportError::EmptyPayloadBytes -pub radroots_transport::RadrootsTransportError::EmptyPayloadId -pub radroots_transport::RadrootsTransportError::EmptyPayloadLabel -pub radroots_transport::RadrootsTransportError::EmptyRequiredTargetSet -pub radroots_transport::RadrootsTransportError::EmptyTargetLabel -pub radroots_transport::RadrootsTransportError::EmptyTargetScope -pub radroots_transport::RadrootsTransportError::EmptyTargetSet -pub radroots_transport::RadrootsTransportError::EmptyTargetUri -pub radroots_transport::RadrootsTransportError::EmptyTransportKind -pub radroots_transport::RadrootsTransportError::FetchPageLimitExceeded -pub radroots_transport::RadrootsTransportError::FetchPageRequestMismatch -pub radroots_transport::RadrootsTransportError::FetchSelectorTooLarge -pub radroots_transport::RadrootsTransportError::InvalidDeliveryDeadline -pub radroots_transport::RadrootsTransportError::InvalidDeliveryOutcome -pub radroots_transport::RadrootsTransportError::InvalidDeliveryRequestId -pub radroots_transport::RadrootsTransportError::InvalidDeliveryTimestamp -pub radroots_transport::RadrootsTransportError::InvalidFetchCursor -pub radroots_transport::RadrootsTransportError::InvalidFetchDeadline -pub radroots_transport::RadrootsTransportError::InvalidFetchLimit -pub radroots_transport::RadrootsTransportError::InvalidFetchRequestId -pub radroots_transport::RadrootsTransportError::InvalidFetchTimeRange -pub radroots_transport::RadrootsTransportError::InvalidObservedAt -pub radroots_transport::RadrootsTransportError::InvalidPayloadBytes -pub radroots_transport::RadrootsTransportError::InvalidPayloadDigest -pub radroots_transport::RadrootsTransportError::InvalidPayloadId -pub radroots_transport::RadrootsTransportError::InvalidPayloadLabel -pub radroots_transport::RadrootsTransportError::InvalidSatisfactionPolicy -pub radroots_transport::RadrootsTransportError::InvalidTargetFingerprint -pub radroots_transport::RadrootsTransportError::InvalidTargetLabel -pub radroots_transport::RadrootsTransportError::InvalidTargetScope -pub radroots_transport::RadrootsTransportError::InvalidTargetUri -pub radroots_transport::RadrootsTransportError::InvalidTransportKind -pub radroots_transport::RadrootsTransportError::MissingDeliveryTargetReceipt -pub radroots_transport::RadrootsTransportError::PayloadDigestMismatch -pub radroots_transport::RadrootsTransportError::RequiredTargetNotRequested -pub radroots_transport::RadrootsTransportError::TargetSetTooLarge -pub radroots_transport::RadrootsTransportError::TransportOutcomeStatusMismatch -pub radroots_transport::RadrootsTransportError::UnexpectedDeliveryTargetReceipt -pub radroots_transport::RadrootsTransportError::UnexpectedFetchEvent -pub radroots_transport::RadrootsTransportError::UnexpectedFetchProvenance -pub radroots_transport::RadrootsTransportError::UnexpectedFetchTargetOutcome -pub radroots_transport::RadrootsTransportError::UnsupportedOperation -impl core::fmt::Display for radroots_transport::error::RadrootsTransportError -pub fn radroots_transport::error::RadrootsTransportError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -pub enum radroots_transport::RadrootsTransportImplementationState -pub radroots_transport::RadrootsTransportImplementationState::Mock -pub radroots_transport::RadrootsTransportImplementationState::Real -pub enum radroots_transport::RadrootsTransportOutcomeKind -pub radroots_transport::RadrootsTransportOutcomeKind::Accepted -pub radroots_transport::RadrootsTransportOutcomeKind::ConnectionFailed -pub radroots_transport::RadrootsTransportOutcomeKind::DeferredUntilImplemented -pub radroots_transport::RadrootsTransportOutcomeKind::Delivered -pub radroots_transport::RadrootsTransportOutcomeKind::DuplicateAccepted -pub radroots_transport::RadrootsTransportOutcomeKind::Forwarded -pub radroots_transport::RadrootsTransportOutcomeKind::PayloadTooLarge -pub radroots_transport::RadrootsTransportOutcomeKind::PolicyDenied -pub radroots_transport::RadrootsTransportOutcomeKind::Rejected -pub radroots_transport::RadrootsTransportOutcomeKind::RouteUnavailable -pub radroots_transport::RadrootsTransportOutcomeKind::Seen -pub radroots_transport::RadrootsTransportOutcomeKind::StoredByGateway -pub radroots_transport::RadrootsTransportOutcomeKind::Timeout -pub radroots_transport::RadrootsTransportOutcomeKind::TransportUnavailable -impl radroots_transport::RadrootsTransportOutcomeKind -pub fn radroots_transport::RadrootsTransportOutcomeKind::as_str(self) -> &'static str -pub fn radroots_transport::RadrootsTransportOutcomeKind::counts_as_satisfied(self, radroots_transport::RadrootsTransportSatisfactionClass) -> bool -pub fn radroots_transport::RadrootsTransportOutcomeKind::target_status(self) -> radroots_transport::RadrootsTransportDeliveryTargetStatus -pub enum radroots_transport::RadrootsTransportPayload -pub radroots_transport::RadrootsTransportPayload::MeshFrameCbor -pub radroots_transport::RadrootsTransportPayload::MeshFrameCbor::bytes: alloc::vec::Vec<u8> -pub radroots_transport::RadrootsTransportPayload::MeshFrameCbor::digest: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::MeshFrameCbor::message_id: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::OpaqueBytes -pub radroots_transport::RadrootsTransportPayload::OpaqueBytes::bytes: alloc::vec::Vec<u8> -pub radroots_transport::RadrootsTransportPayload::OpaqueBytes::digest: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::OpaqueBytes::label: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::SignedEventJson -pub radroots_transport::RadrootsTransportPayload::SignedEventJson::digest: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::SignedEventJson::event_id: alloc::string::String -pub radroots_transport::RadrootsTransportPayload::SignedEventJson::raw_json: alloc::string::String -impl radroots_transport::RadrootsTransportPayload -pub fn radroots_transport::RadrootsTransportPayload::digest(&self) -> &str -pub fn radroots_transport::RadrootsTransportPayload::mesh_frame_cbor(impl core::convert::AsRef<str>, impl core::convert::AsRef<[u8]>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::mesh_frame_cbor_with_digest(impl core::convert::AsRef<str>, impl core::convert::AsRef<[u8]>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::opaque_bytes(impl core::convert::AsRef<str>, impl core::convert::AsRef<[u8]>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::opaque_bytes_with_digest(impl core::convert::AsRef<str>, impl core::convert::AsRef<[u8]>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::payload_kind(&self) -> &'static str -pub fn radroots_transport::RadrootsTransportPayload::unchecked_signed_event_json(impl core::convert::AsRef<str>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::unchecked_signed_event_json_with_digest(impl core::convert::AsRef<str>, impl core::convert::AsRef<str>, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportPayload::validate(&self) -> core::result::Result<(), radroots_transport::error::RadrootsTransportError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportPayload -pub fn radroots_transport::RadrootsTransportPayload::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub enum radroots_transport::RadrootsTransportSatisfactionClass -pub radroots_transport::RadrootsTransportSatisfactionClass::Accepted -pub radroots_transport::RadrootsTransportSatisfactionClass::Delivered -pub radroots_transport::RadrootsTransportSatisfactionClass::DurableOrObserved -pub radroots_transport::RadrootsTransportSatisfactionClass::Forwarded -pub radroots_transport::RadrootsTransportSatisfactionClass::Seen -pub radroots_transport::RadrootsTransportSatisfactionClass::Stored -pub enum radroots_transport::RadrootsTransportSatisfactionPolicy -pub radroots_transport::RadrootsTransportSatisfactionPolicy::All -pub radroots_transport::RadrootsTransportSatisfactionPolicy::All::class: radroots_transport::RadrootsTransportSatisfactionClass -pub radroots_transport::RadrootsTransportSatisfactionPolicy::Any -pub radroots_transport::RadrootsTransportSatisfactionPolicy::Any::class: radroots_transport::RadrootsTransportSatisfactionClass -pub radroots_transport::RadrootsTransportSatisfactionPolicy::NoWait -pub radroots_transport::RadrootsTransportSatisfactionPolicy::Quorum -pub radroots_transport::RadrootsTransportSatisfactionPolicy::Quorum::class: radroots_transport::RadrootsTransportSatisfactionClass -pub radroots_transport::RadrootsTransportSatisfactionPolicy::Quorum::threshold: u16 -pub radroots_transport::RadrootsTransportSatisfactionPolicy::RequiredTargets -pub radroots_transport::RadrootsTransportSatisfactionPolicy::RequiredTargets::class: radroots_transport::RadrootsTransportSatisfactionClass -pub radroots_transport::RadrootsTransportSatisfactionPolicy::RequiredTargets::targets: alloc::vec::Vec<radroots_transport::target::TargetFingerprint> -impl radroots_transport::RadrootsTransportSatisfactionPolicy -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_accepted() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_delivered() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_durable_or_observed() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_forwarded() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_seen() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::all_stored() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_accepted() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_delivered() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_durable_or_observed() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_forwarded() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_seen() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::any_stored() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::is_satisfied_by(&self, usize, usize) -> core::result::Result<bool, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::no_wait() -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_accepted(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_delivered(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_forwarded(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_seen(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::quorum_stored(u16) -> Self -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::required_target_count(&self, usize) -> core::result::Result<usize, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::required_target_fingerprints(&self) -> core::option::Option<&[radroots_transport::target::TargetFingerprint]> -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::required_targets(radroots_transport::RadrootsTransportSatisfactionClass, alloc::vec::Vec<radroots_transport::target::TargetFingerprint>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::target_satisfaction_class(&self) -> core::option::Option<radroots_transport::RadrootsTransportSatisfactionClass> -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::validate_for_target_set(&self, &radroots_transport::target::TargetSet) -> core::result::Result<(), radroots_transport::error::RadrootsTransportError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportSatisfactionPolicy -pub fn radroots_transport::RadrootsTransportSatisfactionPolicy::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub enum radroots_transport::Error +pub radroots_transport::Error::DeliveryReceiptRequestIdMismatch +pub radroots_transport::Error::DeliveryReceiptTargetSetMismatch +pub radroots_transport::Error::DeliveryTargetReceiptAttemptMismatch +pub radroots_transport::Error::DeliveryTargetReceiptStatusMismatch +pub radroots_transport::Error::DuplicateDeliveryTargetReceipt +pub radroots_transport::Error::DuplicateFetchAuthor +pub radroots_transport::Error::DuplicateFetchKind +pub radroots_transport::Error::DuplicateFetchTargetOutcome +pub radroots_transport::Error::DuplicateRequiredTargetFingerprint +pub radroots_transport::Error::DuplicateTargetFingerprint +pub radroots_transport::Error::EmptyDeliveryRequestId +pub radroots_transport::Error::EmptyFetchCursor +pub radroots_transport::Error::EmptyFetchRequestId +pub radroots_transport::Error::EmptyPayloadBytes +pub radroots_transport::Error::EmptyPayloadId +pub radroots_transport::Error::EmptyPayloadLabel +pub radroots_transport::Error::EmptyRequiredTargetSet +pub radroots_transport::Error::EmptyTargetLabel +pub radroots_transport::Error::EmptyTargetScope +pub radroots_transport::Error::EmptyTargetSet +pub radroots_transport::Error::EmptyTargetUri +pub radroots_transport::Error::EmptyTransportKind +pub radroots_transport::Error::FetchPageLimitExceeded +pub radroots_transport::Error::FetchPageRequestMismatch +pub radroots_transport::Error::FetchSelectorTooLarge +pub radroots_transport::Error::InvalidDeliveryDeadline +pub radroots_transport::Error::InvalidDeliveryOutcome +pub radroots_transport::Error::InvalidDeliveryRequestId +pub radroots_transport::Error::InvalidDeliveryTimestamp +pub radroots_transport::Error::InvalidFetchCursor +pub radroots_transport::Error::InvalidFetchDeadline +pub radroots_transport::Error::InvalidFetchLimit +pub radroots_transport::Error::InvalidFetchRequestId +pub radroots_transport::Error::InvalidFetchTimeRange +pub radroots_transport::Error::InvalidObservedAt +pub radroots_transport::Error::InvalidPayloadBytes +pub radroots_transport::Error::InvalidPayloadDigest +pub radroots_transport::Error::InvalidPayloadId +pub radroots_transport::Error::InvalidPayloadLabel +pub radroots_transport::Error::InvalidSatisfactionPolicy +pub radroots_transport::Error::InvalidTargetFingerprint +pub radroots_transport::Error::InvalidTargetLabel +pub radroots_transport::Error::InvalidTargetScope +pub radroots_transport::Error::InvalidTargetUri +pub radroots_transport::Error::InvalidTransportKind +pub radroots_transport::Error::MissingDeliveryTargetReceipt +pub radroots_transport::Error::PayloadDigestMismatch +pub radroots_transport::Error::RequiredTargetNotRequested +pub radroots_transport::Error::TargetSetTooLarge +pub radroots_transport::Error::TransportOutcomeStatusMismatch +pub radroots_transport::Error::UnexpectedDeliveryTargetReceipt +pub radroots_transport::Error::UnexpectedFetchEvent +pub radroots_transport::Error::UnexpectedFetchProvenance +pub radroots_transport::Error::UnexpectedFetchTargetOutcome +pub radroots_transport::Error::UnsupportedOperation +impl core::fmt::Display for radroots_transport::error::Error +pub fn radroots_transport::error::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_transport::DeliveryReceipt impl radroots_transport::sink::DeliveryReceipt pub fn radroots_transport::sink::DeliveryReceipt::for_request(&radroots_transport::sink::DeliveryRequest, alloc::vec::Vec<radroots_transport::sink::DeliveryTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::sink::DeliveryReceipt::is_satisfied(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<bool, radroots_transport::error::Error> pub const fn radroots_transport::sink::DeliveryReceipt::request_id(&self) -> &radroots_transport::sink::DeliveryRequestId +pub fn radroots_transport::sink::DeliveryReceipt::satisfaction(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<radroots_transport::policy::SatisfactionState, radroots_transport::error::Error> pub fn radroots_transport::sink::DeliveryReceipt::target_receipts(&self) -> &[radroots_transport::sink::DeliveryTargetReceipt] pub fn radroots_transport::sink::DeliveryReceipt::validate_for_request(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<(), radroots_transport::error::Error> impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::sink::DeliveryReceipt @@ -679,96 +585,18 @@ pub fn radroots_transport::source::FetchRequest::with_cursor(self, radroots_tran pub fn radroots_transport::source::FetchRequest::with_selector(self, radroots_transport::source::FetchSelector) -> Self impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::source::FetchRequest pub fn radroots_transport::source::FetchRequest::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub struct radroots_transport::RadrootsTransportCapabilities -pub radroots_transport::RadrootsTransportCapabilities::deliver: bool -pub radroots_transport::RadrootsTransportCapabilities::discovery: bool -pub radroots_transport::RadrootsTransportCapabilities::fetch: bool -pub radroots_transport::RadrootsTransportCapabilities::gateway_forwarding: bool -pub radroots_transport::RadrootsTransportCapabilities::receipt_observation: bool -impl radroots_transport::RadrootsTransportCapabilities -pub const fn radroots_transport::RadrootsTransportCapabilities::deliver_and_fetch() -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::deliver_only() -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::fetch_only() -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::none() -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::reticulum_unavailable() -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::with_discovery(self, bool) -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::with_gateway_forwarding(self, bool) -> Self -pub const fn radroots_transport::RadrootsTransportCapabilities::with_receipt_observation(self, bool) -> Self -pub struct radroots_transport::RadrootsTransportDeliveryReceipt -impl radroots_transport::RadrootsTransportDeliveryReceipt -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::for_request(&radroots_transport::RadrootsTransportDeliveryRequest, alloc::vec::Vec<radroots_transport::RadrootsTransportTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::is_satisfied_by(&self, &radroots_transport::RadrootsTransportSatisfactionPolicy) -> core::result::Result<bool, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::new(impl core::convert::Into<alloc::string::String>, radroots_transport::target::TargetSet, alloc::vec::Vec<radroots_transport::RadrootsTransportTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::request_id(&self) -> &str -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::satisfied_target_count(&self, radroots_transport::RadrootsTransportSatisfactionClass) -> usize -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::target_receipts(&self) -> &[radroots_transport::RadrootsTransportTargetReceipt] -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::target_set(&self) -> &radroots_transport::target::TargetSet -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::validate_for_request(&self, &radroots_transport::RadrootsTransportDeliveryRequest) -> core::result::Result<(), radroots_transport::error::RadrootsTransportError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportDeliveryReceipt -pub fn radroots_transport::RadrootsTransportDeliveryReceipt::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub struct radroots_transport::RadrootsTransportDeliveryRequest -impl radroots_transport::RadrootsTransportDeliveryRequest -pub fn radroots_transport::RadrootsTransportDeliveryRequest::new(impl core::convert::Into<alloc::string::String>, radroots_transport::RadrootsTransportPayload, radroots_transport::target::TargetSet, radroots_transport::RadrootsTransportSatisfactionPolicy) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportDeliveryRequest::now_ms(&self) -> i64 -pub fn radroots_transport::RadrootsTransportDeliveryRequest::payload(&self) -> &radroots_transport::RadrootsTransportPayload -pub fn radroots_transport::RadrootsTransportDeliveryRequest::request_id(&self) -> &str -pub fn radroots_transport::RadrootsTransportDeliveryRequest::satisfaction_policy(&self) -> &radroots_transport::RadrootsTransportSatisfactionPolicy -pub fn radroots_transport::RadrootsTransportDeliveryRequest::target_set(&self) -> &radroots_transport::target::TargetSet -pub fn radroots_transport::RadrootsTransportDeliveryRequest::try_with_now_ms(self, i64) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportDeliveryRequest -pub fn radroots_transport::RadrootsTransportDeliveryRequest::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub struct radroots_transport::RadrootsTransportFetchReceipt -pub radroots_transport::RadrootsTransportFetchReceipt::fetched_count: usize -pub radroots_transport::RadrootsTransportFetchReceipt::request_id: alloc::string::String -pub radroots_transport::RadrootsTransportFetchReceipt::target_receipts: alloc::vec::Vec<radroots_transport::RadrootsTransportTargetReceipt> -impl radroots_transport::RadrootsTransportFetchReceipt -pub fn radroots_transport::RadrootsTransportFetchReceipt::new(impl core::convert::Into<alloc::string::String>, alloc::vec::Vec<radroots_transport::RadrootsTransportTargetReceipt>, usize) -> Self -pub struct radroots_transport::RadrootsTransportFetchRequest -pub radroots_transport::RadrootsTransportFetchRequest::request_id: alloc::string::String -pub radroots_transport::RadrootsTransportFetchRequest::target_set: radroots_transport::target::TargetSet -impl radroots_transport::RadrootsTransportFetchRequest -pub fn radroots_transport::RadrootsTransportFetchRequest::new(impl core::convert::Into<alloc::string::String>, radroots_transport::target::TargetSet) -> Self -pub struct radroots_transport::RadrootsTransportOutcome -pub radroots_transport::RadrootsTransportOutcome::code: core::option::Option<alloc::string::String> -pub radroots_transport::RadrootsTransportOutcome::kind: radroots_transport::RadrootsTransportOutcomeKind -pub radroots_transport::RadrootsTransportOutcome::message: core::option::Option<alloc::string::String> -pub radroots_transport::RadrootsTransportOutcome::status: radroots_transport::RadrootsTransportDeliveryTargetStatus -impl radroots_transport::RadrootsTransportOutcome -pub fn radroots_transport::RadrootsTransportOutcome::new(radroots_transport::RadrootsTransportOutcomeKind) -> Self -pub fn radroots_transport::RadrootsTransportOutcome::validate(&self) -> core::result::Result<(), radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::RadrootsTransportOutcome::with_code(self, impl core::convert::Into<alloc::string::String>) -> Self -pub fn radroots_transport::RadrootsTransportOutcome::with_message(self, impl core::convert::Into<alloc::string::String>) -> Self -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportOutcome -pub fn radroots_transport::RadrootsTransportOutcome::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub struct radroots_transport::RadrootsTransportStatus -pub radroots_transport::RadrootsTransportStatus::availability: crate::RadrootsTransportCapabilityAvailability -pub radroots_transport::RadrootsTransportStatus::capabilities: radroots_transport::RadrootsTransportCapabilities -pub radroots_transport::RadrootsTransportStatus::configured: bool -pub radroots_transport::RadrootsTransportStatus::endpoint_uri: core::option::Option<alloc::string::String> -pub radroots_transport::RadrootsTransportStatus::implementation: radroots_transport::RadrootsTransportImplementationState -pub radroots_transport::RadrootsTransportStatus::kind: radroots_transport::TransportId -pub radroots_transport::RadrootsTransportStatus::maturity: crate::RadrootsTransportCapabilityMaturity -pub radroots_transport::RadrootsTransportStatus::message: alloc::string::String -pub radroots_transport::RadrootsTransportStatus::profile_id: core::option::Option<alloc::string::String> -pub radroots_transport::RadrootsTransportStatus::usable_for_delivery: bool -impl radroots_transport::RadrootsTransportStatus -pub fn radroots_transport::RadrootsTransportStatus::new(radroots_transport::TransportId, bool, radroots_transport::RadrootsTransportImplementationState, bool, impl core::convert::Into<alloc::string::String>) -> Self -pub fn radroots_transport::RadrootsTransportStatus::with_availability(self, RadrootsTransportCapabilityAvailability) -> Self -pub fn radroots_transport::RadrootsTransportStatus::with_capabilities(self, radroots_transport::RadrootsTransportCapabilities) -> Self -pub fn radroots_transport::RadrootsTransportStatus::with_endpoint_uri(self, impl core::convert::Into<alloc::string::String>) -> Self -pub fn radroots_transport::RadrootsTransportStatus::with_maturity(self, RadrootsTransportCapabilityMaturity) -> Self -pub fn radroots_transport::RadrootsTransportStatus::with_profile_id(self, impl core::convert::Into<alloc::string::String>) -> Self -pub struct radroots_transport::RadrootsTransportTargetReceipt -pub radroots_transport::RadrootsTransportTargetReceipt::attempted: bool -pub radroots_transport::RadrootsTransportTargetReceipt::outcome: radroots_transport::RadrootsTransportOutcome -pub radroots_transport::RadrootsTransportTargetReceipt::status: radroots_transport::RadrootsTransportDeliveryTargetStatus -pub radroots_transport::RadrootsTransportTargetReceipt::target: radroots_transport::target::Target -impl radroots_transport::RadrootsTransportTargetReceipt -pub fn radroots_transport::RadrootsTransportTargetReceipt::attempted(radroots_transport::target::Target, radroots_transport::RadrootsTransportOutcome) -> Self -pub fn radroots_transport::RadrootsTransportTargetReceipt::new(radroots_transport::target::Target, radroots_transport::RadrootsTransportOutcome) -> Self -pub fn radroots_transport::RadrootsTransportTargetReceipt::skipped(radroots_transport::target::Target, radroots_transport::RadrootsTransportOutcome) -> Self -impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::RadrootsTransportTargetReceipt -pub fn radroots_transport::RadrootsTransportTargetReceipt::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> +pub struct radroots_transport::SinkFailure +impl radroots_transport::sink::SinkFailure +pub fn radroots_transport::sink::SinkFailure::code(&self) -> &str +pub fn radroots_transport::sink::SinkFailure::for_request(&radroots_transport::sink::DeliveryRequest, impl core::convert::Into<alloc::string::String>, radroots_transport::outcome::Retryability, core::option::Option<u64>, core::option::Option<alloc::string::String>, alloc::vec::Vec<radroots_transport::sink::DeliveryTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::sink::SinkFailure::invalid_contract(&radroots_transport::sink::DeliveryRequest) -> Self +pub fn radroots_transport::sink::SinkFailure::message(&self) -> core::option::Option<&str> +pub fn radroots_transport::sink::SinkFailure::partial_evidence(&self) -> &[radroots_transport::sink::DeliveryTargetReceipt] +pub const fn radroots_transport::sink::SinkFailure::retry_after_unix_ms(&self) -> core::option::Option<u64> +pub const fn radroots_transport::sink::SinkFailure::retryability(&self) -> radroots_transport::outcome::Retryability +pub fn radroots_transport::sink::SinkFailure::validate_for_request(&self, &radroots_transport::sink::DeliveryRequest) -> core::result::Result<(), radroots_transport::error::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::sink::SinkFailure +pub fn radroots_transport::sink::SinkFailure::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::SinkStatus impl radroots_transport::SinkStatus pub const fn radroots_transport::SinkStatus::availability(&self) -> radroots_transport::capability::Availability @@ -794,21 +622,22 @@ impl radroots_transport::target::Target pub fn radroots_transport::target::Target::fingerprint(&self) -> &radroots_transport::target::TargetFingerprint pub fn radroots_transport::target::Target::kind(&self) -> &radroots_transport::TransportId pub fn radroots_transport::target::Target::label(&self) -> core::option::Option<&radroots_transport::target::TargetLabel> -pub fn radroots_transport::target::Target::local(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::local_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::Target::local(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::local_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::scope(&self) -> core::option::Option<&radroots_transport::target::TargetScope> pub fn radroots_transport::target::Target::uri(&self) -> &radroots_transport::target::EndpointUri impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::Target pub fn radroots_transport::target::Target::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub struct radroots_transport::TargetSet impl radroots_transport::target::TargetSet +pub fn radroots_transport::target::TargetSet::contains(&self, &radroots_transport::target::TargetFingerprint) -> bool pub fn radroots_transport::target::TargetSet::is_empty(&self) -> bool pub fn radroots_transport::target::TargetSet::len(&self) -> usize -pub fn radroots_transport::target::TargetSet::new(alloc::vec::Vec<radroots_transport::target::Target>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::target::TargetSet::new(alloc::vec::Vec<radroots_transport::target::Target>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::TargetSet::targets(&self) -> &[radroots_transport::target::Target] impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::TargetSet pub fn radroots_transport::target::TargetSet::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> @@ -820,8 +649,8 @@ pub const radroots_transport::TransportId::RADROOTSD: Self pub const radroots_transport::TransportId::RETICULUM: Self pub fn radroots_transport::TransportId::as_str(&self) -> &str pub fn radroots_transport::TransportId::canonical_label(&self) -> alloc::string::String -pub fn radroots_transport::TransportId::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> -pub fn radroots_transport::TransportId::parse_canonical(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::RadrootsTransportError> +pub fn radroots_transport::TransportId::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::TransportId::parse_canonical(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> impl core::convert::AsRef<str> for radroots_transport::TransportId pub fn radroots_transport::TransportId::as_ref(&self) -> &str impl core::convert::From<radroots_protocol::capability::v1::TransportKind> for radroots_transport::TransportId @@ -829,28 +658,26 @@ pub fn radroots_transport::TransportId::from(radroots_protocol::capability::v1:: impl core::convert::From<radroots_transport::TransportId> for radroots_protocol::capability::v1::TransportKind pub fn radroots_protocol::capability::v1::TransportKind::from(radroots_transport::TransportId) -> Self impl core::convert::TryFrom<&str> for radroots_transport::TransportId -pub type radroots_transport::TransportId::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::TransportId::Error = radroots_transport::error::Error pub fn radroots_transport::TransportId::try_from(&str) -> core::result::Result<Self, Self::Error> impl core::convert::TryFrom<alloc::string::String> for radroots_transport::TransportId -pub type radroots_transport::TransportId::Error = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::TransportId::Error = radroots_transport::error::Error pub fn radroots_transport::TransportId::try_from(alloc::string::String) -> core::result::Result<Self, Self::Error> impl core::fmt::Display for radroots_transport::TransportId pub fn radroots_transport::TransportId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::str::traits::FromStr for radroots_transport::TransportId -pub type radroots_transport::TransportId::Err = radroots_transport::error::RadrootsTransportError +pub type radroots_transport::TransportId::Err = radroots_transport::error::Error pub fn radroots_transport::TransportId::from_str(&str) -> core::result::Result<Self, Self::Err> impl serde_core::ser::Serialize for radroots_transport::TransportId pub fn radroots_transport::TransportId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::TransportId pub fn radroots_transport::TransportId::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> -pub const radroots_transport::RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES: usize pub const radroots_transport::TARGET_SET_MAX_ITEMS: usize pub const radroots_transport::TRANSPORT_ID_MAX_BYTES: usize pub trait radroots_transport::EventSink: core::marker::Send + core::marker::Sync -pub fn radroots_transport::EventSink::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::error::Error>> +pub fn radroots_transport::EventSink::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::sink::SinkFailure>> pub fn radroots_transport::EventSink::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::SinkStatus, radroots_transport::error::Error>> pub trait radroots_transport::EventSource: core::marker::Send + core::marker::Sync pub fn radroots_transport::EventSource::fetch(&self, radroots_transport::source::FetchRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::source::FetchPage, radroots_transport::error::Error>> pub fn radroots_transport::EventSource::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::SourceStatus, radroots_transport::error::Error>> pub type radroots_transport::BoxFuture<'a, T> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = T> + core::marker::Send + 'a)>> -pub type radroots_transport::Error = radroots_transport::error::RadrootsTransportError diff --git a/docs/specs/radroots_crates_release_v1.md b/docs/specs/radroots_crates_release_v1.md @@ -292,7 +292,7 @@ The final review makes the following deliberate changes: - `trade` - `wire` - **Permitted root exports:** `Event`, `EventDraft`, `SignedEvent`, `VerifiedEvent`, `EventId`, `EventKind`, `EventTag`, `Error` + **Permitted root exports:** `Event`, `GenericEventDraft`, `SignedEvent`, `VerifiedEvent`, `EventId`, `EventKind`, `EventTag`, `Error` **Explicitly forbidden.** Live Nostr clients, relay pools, signing backends, SQLite, outbox claims, retry scheduling, application state, or duplicate trade/order identifier concepts. @@ -621,7 +621,7 @@ The final review makes the following deliberate changes: - `push` - `status` - **Permitted root exports:** `Engine`, `PullRequest`, `PullReceipt`, `PushRequest`, `PushReceipt`, `SyncStatus`, `Error` + **Permitted root exports:** `Engine`, `PullRequest`, `PullReceipt`, `PushRequest`, `PushPreparation`, `PushStatus`, `SigningRunReceipt`, `AdmissionRunReceipt`, `DeliveryExecutionReceipt`, `SyncStatus`, `Error` **Explicitly forbidden.** Creating an executor, spawning hidden workers, installing timers globally, owning process lifecycle, storing UI state, or transport-specific branches outside adapters. @@ -1093,7 +1093,7 @@ host-owned databases ### 13.2 Signing -`radroots_signing::Signer` signs a frozen canonical `EventDraft`. The signing layer: +`radroots_signing::Signer` signs an immutable canonical `AuthoredEventPlan`. The signing layer: - authorizes actor role and expected public key before invoking a signer; - verifies the signer result matches the exact draft; diff --git a/docs/specs/radroots_crates_release_v1.toml b/docs/specs/radroots_crates_release_v1.toml @@ -201,7 +201,7 @@ modules = [ ] root_exports = [ "Event", - "EventDraft", + "GenericEventDraft", "SignedEvent", "VerifiedEvent", "EventId", @@ -534,7 +534,11 @@ root_exports = [ "PullRequest", "PullReceipt", "PushRequest", - "PushReceipt", + "PushPreparation", + "PushStatus", + "SigningRunReceipt", + "AdmissionRunReceipt", + "DeliveryExecutionReceipt", "SyncStatus", "Error", ] diff --git a/fuzz/event_codec/Cargo.lock b/fuzz/event_codec/Cargo.lock @@ -861,10 +861,12 @@ name = "radroots_storage" version = "0.1.0-alpha" dependencies = [ "radroots_event", + "radroots_event_codec", "radroots_protocol", "radroots_trade", "radroots_transport", "serde", + "sha2", ] [[package]] diff --git a/tools/xtask/src/api_qualification.rs b/tools/xtask/src/api_qualification.rs @@ -23,6 +23,11 @@ pub fn run(root: &Path) -> Result<(), String> { eprintln!("cargo {}", args.join(" ")); let status = Command::new("cargo") .args(&args) + // Keep rustdoc-JSON qualification distinct from ordinary `cargo doc` + // artifacts in the extbuild-owned target tree. Cargo otherwise may + // reuse an HTML-only rustdoc fingerprint and leave semver-checks + // without the JSON artifact it requested. + .env("CARGO_PROFILE_DEV_DEBUG", "none") .current_dir(root) .status() .map_err(|error| format!("failed to start cargo-semver-checks: {error}"))?; @@ -43,7 +48,7 @@ fn load(root: &Path) -> Result<Contract, String> { fn validate(contract: &Contract, expected_packages: usize) -> Result<(), String> { if contract.schema_version != 1 || contract.tool != "cargo-semver-checks" - || contract.release_type != "minor" + || contract.release_type != "major" || contract.feature_policy != "all" || contract.baseline_revision.len() < 7 { @@ -121,6 +126,8 @@ mod tests { .expect("workspace root"); let contract = load(root).expect("contract"); validate(&contract, 17).expect("valid contract"); - assert!(invocation(&contract, "radroots_core").contains(&"--all-features".to_owned())); + let invocation = invocation(&contract, "radroots_core"); + assert!(invocation.contains(&"--all-features".to_owned())); + assert!(invocation.ends_with(&["--release-type".to_owned(), "major".to_owned()])); } } diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -9867,7 +9867,7 @@ domains = ["profile", "farm", "operational_listing", "trade"] [shared_types] public = [ "Nip01EventWireParts", - "EventDraft", + "GenericEventDraft", "SignedEvent", "EventEnvelope", "EventRef",