lib

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

commit c804f77f4ef827a05a4c4b6d1ba8f75f58902315
parent f90ad35cc7027d9a10dba2a3510fb56255cd8b65
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 20:34:11 +0000

local-events: add relay observed delivery evidence

- add observed relay delivery state for relay-fetched event evidence
- keep publish acknowledgements separate from relay observation provenance
- validate observed evidence shapes and reject mixed acknowledgement evidence
- cover observed and unknown-fetch provenance in relay delivery tests

Diffstat:
Mcrates/local_events/src/relay_delivery.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/local_events/tests/relay_delivery.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 128 insertions(+), 5 deletions(-)

diff --git a/crates/local_events/src/relay_delivery.rs b/crates/local_events/src/relay_delivery.rs @@ -14,6 +14,7 @@ pub enum RelayDeliveryState { Pending, Acknowledged, Failed, + Observed, } impl RelayDeliveryState { @@ -22,6 +23,7 @@ impl RelayDeliveryState { Self::Pending => "pending", Self::Acknowledged => "acknowledged", Self::Failed => "failed", + Self::Observed => "observed", } } } @@ -49,6 +51,8 @@ pub struct RelayDeliveryEvidence { pub target_relays: Vec<String>, pub connected_relays: Vec<String>, pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub observed_relays: Vec<String>, pub failed_relays: Vec<RelayDeliveryFailure>, } @@ -63,6 +67,7 @@ impl RelayDeliveryEvidence { target_relays, Vec::<String>::new(), Vec::<String>::new(), + Vec::<String>::new(), Vec::new(), ) } @@ -86,6 +91,31 @@ impl RelayDeliveryEvidence { target_relays, connected_relays, acknowledged_relays, + Vec::<String>::new(), + failed_relays, + ) + } + + pub fn observed<I, S, J, T, K, U>( + target_relays: I, + connected_relays: J, + observed_relays: K, + failed_relays: Vec<RelayDeliveryFailure>, + ) -> Result<Self, LocalEventsError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + J: IntoIterator<Item = T>, + T: AsRef<str>, + K: IntoIterator<Item = U>, + U: AsRef<str>, + { + Self::build( + RelayDeliveryState::Observed, + target_relays, + connected_relays, + Vec::<String>::new(), + observed_relays, failed_relays, ) } @@ -106,6 +136,7 @@ impl RelayDeliveryEvidence { target_relays, connected_relays, Vec::<String>::new(), + Vec::<String>::new(), failed_relays, ) } @@ -114,6 +145,7 @@ impl RelayDeliveryEvidence { validate_relay_set("target_relays", &self.target_relays, true)?; validate_relay_set("connected_relays", &self.connected_relays, false)?; validate_relay_set("acknowledged_relays", &self.acknowledged_relays, false)?; + validate_relay_set("observed_relays", &self.observed_relays, false)?; for failure in &self.failed_relays { let normalized = normalize_relay_url_for_evidence("failed_relays.relay_url", &failure.relay_url)?; @@ -129,9 +161,12 @@ impl RelayDeliveryEvidence { } match self.state { RelayDeliveryState::Pending => { - if !self.acknowledged_relays.is_empty() || !self.failed_relays.is_empty() { + if !self.acknowledged_relays.is_empty() + || !self.observed_relays.is_empty() + || !self.failed_relays.is_empty() + { return Err(invalid_evidence( - "pending delivery evidence must not include acknowledged or failed relays", + "pending delivery evidence must not include acknowledged, observed, or failed relays", )); } } @@ -141,11 +176,31 @@ impl RelayDeliveryEvidence { "acknowledged delivery evidence requires acknowledged_relays", )); } + if !self.observed_relays.is_empty() { + return Err(invalid_evidence( + "acknowledged delivery evidence must not include observed_relays", + )); + } } RelayDeliveryState::Failed => { - if !self.acknowledged_relays.is_empty() || self.failed_relays.is_empty() { + if !self.acknowledged_relays.is_empty() + || !self.observed_relays.is_empty() + || self.failed_relays.is_empty() + { + return Err(invalid_evidence( + "failed delivery evidence requires failed_relays and no acknowledged or observed relays", + )); + } + } + RelayDeliveryState::Observed => { + if !self.acknowledged_relays.is_empty() { + return Err(invalid_evidence( + "observed delivery evidence must not include acknowledged_relays", + )); + } + if self.observed_relays.is_empty() && self.connected_relays.is_empty() { return Err(invalid_evidence( - "failed delivery evidence requires failed_relays and no acknowledged_relays", + "observed delivery evidence requires connected_relays or observed_relays", )); } } @@ -168,11 +223,12 @@ impl RelayDeliveryEvidence { Ok(evidence) } - fn build<I, S, J, T, K, U>( + fn build<I, S, J, T, K, U, L, V>( state: RelayDeliveryState, target_relays: I, connected_relays: J, acknowledged_relays: K, + observed_relays: L, failed_relays: Vec<RelayDeliveryFailure>, ) -> Result<Self, LocalEventsError> where @@ -182,12 +238,15 @@ impl RelayDeliveryEvidence { T: AsRef<str>, K: IntoIterator<Item = U>, U: AsRef<str>, + L: IntoIterator<Item = V>, + V: AsRef<str>, { let evidence = Self { state, target_relays: normalize_required_relay_set("target_relays", target_relays)?, connected_relays: normalize_relay_set("connected_relays", connected_relays)?, acknowledged_relays: normalize_relay_set("acknowledged_relays", acknowledged_relays)?, + observed_relays: normalize_relay_set("observed_relays", observed_relays)?, failed_relays, }; evidence.validate()?; diff --git a/crates/local_events/tests/relay_delivery.rs b/crates/local_events/tests/relay_delivery.rs @@ -58,6 +58,55 @@ fn acknowledged_delivery_evidence_uses_canonical_failure_fields() { } #[test] +fn observed_delivery_evidence_tracks_observed_relays_without_acknowledgement() { + let evidence = RelayDeliveryEvidence::observed( + ["wss://relay-a.example", "wss://relay-b.example"], + [" wss://relay-a.example ", "wss://relay-b.example"], + ["wss://relay-b.example"], + Vec::new(), + ) + .expect("observed evidence"); + + assert_eq!(evidence.state, RelayDeliveryState::Observed); + assert!(evidence.acknowledged_relays.is_empty()); + assert_eq!( + evidence.to_json_value().expect("json"), + json!({ + "state": "observed", + "target_relays": ["wss://relay-a.example", "wss://relay-b.example"], + "connected_relays": ["wss://relay-a.example", "wss://relay-b.example"], + "acknowledged_relays": [], + "observed_relays": ["wss://relay-b.example"], + "failed_relays": [] + }) + ); +} + +#[test] +fn observed_delivery_evidence_allows_unknown_exact_relay_when_connected() { + let evidence = RelayDeliveryEvidence::observed( + ["wss://relay-a.example", "wss://relay-b.example"], + ["wss://relay-a.example", "wss://relay-b.example"], + Vec::<String>::new(), + Vec::new(), + ) + .expect("observed evidence"); + + assert_eq!(evidence.state, RelayDeliveryState::Observed); + assert!(evidence.observed_relays.is_empty()); + assert_eq!( + evidence.to_json_value().expect("json"), + json!({ + "state": "observed", + "target_relays": ["wss://relay-a.example", "wss://relay-b.example"], + "connected_relays": ["wss://relay-a.example", "wss://relay-b.example"], + "acknowledged_relays": [], + "failed_relays": [] + }) + ); +} + +#[test] fn failed_delivery_evidence_requires_failures_without_acknowledgements() { let evidence = RelayDeliveryEvidence::failed( ["wss://relay-a.example"], @@ -72,6 +121,21 @@ fn failed_delivery_evidence_requires_failures_without_acknowledgements() { } #[test] +fn acknowledged_delivery_evidence_rejects_observed_relays() { + let err = RelayDeliveryEvidence::from_json_value(&json!({ + "state": "acknowledged", + "target_relays": ["wss://relay-a.example"], + "connected_relays": ["wss://relay-a.example"], + "acknowledged_relays": ["wss://relay-a.example"], + "observed_relays": ["wss://relay-a.example"], + "failed_relays": [] + })) + .expect_err("invalid evidence"); + + assert!(err.to_string().contains("observed_relays")); +} + +#[test] fn delivery_evidence_fingerprint_uses_target_relays() { let evidence = RelayDeliveryEvidence::acknowledged( ["wss://relay-b.example", "wss://relay-a.example"],