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:
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",