lib

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

commit 7acb2c09a64014786d635b0fbfd101460b73aaa3
parent 0fe3881f88753fcb72be17b51274dd932fc5714a
Author: triesap <tyson@radroots.org>
Date:   Mon, 21 Sep 2026 07:56:49 +0000

transport_nostr: retain skipped target attempt evidence

- Carry entered publication state through private relay results
- Keep expired queued targets unattempted in delivery receipts
- Reject skipped outcomes that contradict acceptance evidence
- Verify loopback behavior, coverage and workspace contracts

Diffstat:
Mcontracts/architecture/decisions/nostr_exact_delivery.v1.json | 1+
Mcrates/transport_nostr/README.md | 12++++++++----
Mcrates/transport_nostr/src/sink.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/transport_nostr/tests/exact_delivery.rs | 5+++++
4 files changed, 93 insertions(+), 12 deletions(-)

diff --git a/contracts/architecture/decisions/nostr_exact_delivery.v1.json b/contracts/architecture/decisions/nostr_exact_delivery.v1.json @@ -8,6 +8,7 @@ "connection": "SDK authentication, subscription requests and exact publication share one serialized writer on the existing DNS-pinned and TLS-verified connection. No second socket engine, executor, adapter task, event rewrite registry, signer or durable journal is introduced.", "lifetime": "An instance-local registry contains only the configured relay keys and weak connection references. SDK sink destruction or write failure revokes raw send authority. Reconnection installs a distinct writer; an old handle cannot switch to the replacement connection.", "receipt": "Subscribe to the same relay notification stream before sending. Only a matching event-ID OK may accept or reject this attempt. Other IDs cannot satisfy it. Timeout, disconnect, shutdown, notification loss and missing results cannot invent acceptance.", + "attempt_evidence": "A target that expires while queued or before its publication future enters the remote path remains skipped. Once the path starts, attempted remains true even if connection or acknowledgement fails; it does not prove that bytes reached the wire or a remote effect occurred. A skipped target cannot claim Accepted or Delivered. Missing internal results cannot establish absence of attempted work.", "cancellation": "Futures are caller-polled. Cancelling a write releases its serialization guard but does not prove that no frame was buffered or published. Durable retry, settlement and late facts remain caller-owned.", "bounds": "Preserve configured target access, bounded relay inventory, connection concurrency, frame bounds, timeouts and reconnect suppression. Queued relay attempts consume the same frozen operation deadline.", "compatibility": "Public API, generic transport SPI, dependency versions, persisted event schemas, verification thresholds and release authority remain unchanged." diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -190,10 +190,10 @@ exact relay provenance and that current checkpoint. Event limits, absolute deadlines, explicit cancellation, source closure, and stable repeated terminal results follow the generic subscription contract. -Delivery validates an already signed Radroots event and sends its retained JSON, attempts -each configured writable target once, and returns one normalized receipt entry per -requested target. Relay rejection, authentication requirements, rate limits, -timeouts, connection failures, missing results, and partial acceptance remain +Delivery validates an already signed Radroots event and sends its retained JSON, +admits at most one attempt per configured writable target, and returns one +normalized receipt entry per requested target. Relay rejection, authentication +requirements, rate limits, timeouts, connection failures, missing results, and partial acceptance remain explicit; this crate never retries, falls back to another transport, or rewrites an unknown result as success. @@ -214,6 +214,10 @@ Queued relay batches consume that same frozen deadline; they never receive a new timeout after an earlier relay stalls. Bounded local normalization retains the events and distinct outcomes already collected when network work ends, so one timed-out relay cannot erase another relay's earlier successful evidence. +An expired queued target remains unattempted. The attempted flag records entry +into connection/publication work, not proof that bytes reached the wire or that +a remote effect occurred. A skipped target cannot report acceptance; missing +results cannot prove that an attempt was absent. Dropping an unpolled fetch, subscription-start, or delivery future performs no I/O. Once polled, cancellation is best effort at the socket boundary. For diff --git a/crates/transport_nostr/src/sink.rs b/crates/transport_nostr/src/sink.rs @@ -14,6 +14,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Debug)] pub(crate) struct RelayPublishResult { relay: RelayUrl, + attempted: bool, outcome: DeliveryOutcome, } @@ -100,10 +101,12 @@ impl RelayClient for LiveRelayClient { let event = event.clone(); async move { let url = relay.as_str().to_owned(); + let mut attempted = false; let attempt = async { if tokio::time::Instant::now() >= deadline { return Err("timeout".to_owned()); } + attempted = true; self.client .add_relay(url.as_str()) .await @@ -119,7 +122,11 @@ impl RelayClient for LiveRelayClient { Ok(Err(error)) => status::delivery_failure(&error), Ok(Ok(outcome)) => outcome, }; - RelayPublishResult { relay, outcome } + RelayPublishResult { + relay, + attempted, + outcome, + } } })) .buffered(max_connections) @@ -231,7 +238,10 @@ impl NostrTransport { let mut by_relay = BTreeMap::new(); let observed_at_unix_ms = unix_time_ms().max(now_unix_ms); for result in results { - if !expected.contains(&result.relay) || by_relay.contains_key(&result.relay) { + if !expected.contains(&result.relay) + || by_relay.contains_key(&result.relay) + || (!result.attempted && status::delivery_succeeded(&result.outcome)) + { return Err(SinkFailure::invalid_contract(&request)); } let succeeded = status::delivery_succeeded(&result.outcome); @@ -241,19 +251,27 @@ impl NostrTransport { result.outcome.is_retryable(), observed_at_unix_ms, ); - by_relay.insert(result.relay, result.outcome); + by_relay.insert(result.relay, (result.outcome, result.attempted)); } let mut receipts = skipped; for (relay, target) in requested { - let outcome = by_relay.remove(&relay).unwrap_or_else(|| { + let (outcome, attempted) = by_relay.remove(&relay).unwrap_or_else(|| { self.status .record_write(&relay, false, true, observed_at_unix_ms); - DeliveryOutcome::unavailable() - .with_detail("missing_result", "relay returned no result") - .expect("static normalized outcome") + ( + DeliveryOutcome::unavailable() + .with_detail("missing_result", "relay returned no result") + .expect("static normalized outcome"), + true, + ) + }); + receipts.push(if attempted { + DeliveryTargetReceipt::attempted(target, outcome) + } else { + DeliveryTargetReceipt::skipped(target, outcome) + .map_err(|_| SinkFailure::invalid_contract(&request))? }); - receipts.push(DeliveryTargetReceipt::attempted(target, outcome)); } DeliveryReceipt::for_request(&request, receipts) .map_err(|_| SinkFailure::invalid_contract(&request)) @@ -318,6 +336,7 @@ mod tests { relays .into_iter() .map(|relay| RelayPublishResult { + attempted: true, outcome: self .outcomes .get(&relay) @@ -538,6 +557,7 @@ mod tests { let missing = futures::executor::block_on( scripted(vec![RelayPublishResult { relay: one.clone(), + attempted: true, outcome: DeliveryOutcome::accepted(), }]) .deliver(request()), @@ -548,10 +568,12 @@ mod tests { let duplicate = scripted(vec![ RelayPublishResult { relay: one.clone(), + attempted: true, outcome: DeliveryOutcome::accepted(), }, RelayPublishResult { relay: one, + attempted: true, outcome: DeliveryOutcome::accepted(), }, ]); @@ -565,6 +587,7 @@ mod tests { let other = RelayUrl::parse("wss://other.example", RelayUrlPolicy::Public).expect("other"); let unexpected = scripted(vec![RelayPublishResult { relay: other, + attempted: true, outcome: DeliveryOutcome::accepted(), }]); assert_eq!( @@ -592,6 +615,54 @@ mod tests { } #[test] + fn skipped_results_remain_skipped_and_cannot_claim_acceptance() { + let one = RelayUrl::parse("wss://one.example", RelayUrlPolicy::Public).unwrap(); + let two = RelayUrl::parse("wss://two.example", RelayUrlPolicy::Public).unwrap(); + let receipt = futures::executor::block_on( + scripted(vec![ + RelayPublishResult { + relay: one.clone(), + attempted: false, + outcome: status::delivery_failure("timeout"), + }, + RelayPublishResult { + relay: two, + attempted: true, + outcome: DeliveryOutcome::accepted(), + }, + ]) + .deliver(request()), + ) + .unwrap(); + assert!(!receipt.target_receipts()[0].was_attempted()); + assert_eq!( + receipt.target_receipts()[0].outcome().code(), + Some("timeout") + ); + assert!(receipt.target_receipts()[1].was_attempted()); + assert_eq!( + receipt.target_receipts()[1].outcome(), + &DeliveryOutcome::accepted() + ); + for outcome in [DeliveryOutcome::accepted(), DeliveryOutcome::delivered()] { + let transport = scripted(vec![RelayPublishResult { + relay: one.clone(), + attempted: false, + outcome, + }]); + let failure = futures::executor::block_on(transport.deliver(request())).unwrap_err(); + assert_eq!(failure.code(), "invalid_transport_contract"); + assert!( + transport + .relay_status() + .relays() + .iter() + .all(|relay| relay.write().last_success_unix_ms().is_none()) + ); + } + } + + #[test] fn live_relay_client_accepts_an_empty_batch_without_io() { let client = LiveRelayClient::isolated(); let results = futures::executor::block_on(client.publish( diff --git a/crates/transport_nostr/tests/exact_delivery.rs b/crates/transport_nostr/tests/exact_delivery.rs @@ -250,6 +250,11 @@ async fn queued_delivery_targets_cannot_start_after_the_shared_deadline() { .all(|target| !target.outcome().satisfies(SatisfactionClass::Accepted)) ); assert!(second.accept().now_or_never().is_none()); + assert!(receipt.target_receipts()[0].was_attempted()); + assert!( + !receipt.target_receipts()[1].was_attempted(), + "a target expired while queued never entered remote publication" + ); server.abort(); assert!(server.await.unwrap_err().is_cancelled()); }