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