lib

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

commit 5876e44afd487563f11fc914d4daa3ecfba48216
parent 2ccb582a1a303f5eb9e01f42589da83883f30585
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 16:36:49 +0000

transport: add bounded event source selectors

- define canonical kind, author, and inclusive event-time constraints
- bind selectors into fetch page validation and serialized requests
- translate selectors into hardened Nostr relay filters
- record and verify the mobile shared-engine corrective checkpoint

Diffstat:
Mcrates/transport/README.md | 7+++++--
Mcrates/transport/src/error.rs | 20++++++++++++++++++++
Mcrates/transport/src/source.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/source_contract.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/transport_nostr/README.md | 10++++++----
Mcrates/transport_nostr/src/source.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mdocs/api/radroots_transport.txt | 34++++++++++++++++++++++++++++++++++
Mdocs/implementation/DEVIATIONS.md | 1+
Mdocs/implementation/deviations.toml | 26++++++++++++++++++++++++++
9 files changed, 395 insertions(+), 16 deletions(-)

diff --git a/crates/transport/README.md b/crates/transport/README.md @@ -19,7 +19,8 @@ The authoritative package charter is the canonical [`Target`] values. 2. [`TargetSet`] rejects an empty, oversized, or duplicate-fingerprint set. 3. The caller creates a bounded [`FetchRequest`] or [`DeliveryRequest`] with a - request identity and absolute deadline. + request identity and absolute deadline. A fetch may carry a validated + [`FetchSelector`] for exact kinds, authors, and inclusive event-time bounds. 4. A dyn-compatible [`EventSource`] or [`EventSink`] implementation performs only the requested operation. 5. The caller validates [`FetchPage`] or [`DeliveryReceipt`] against the @@ -30,6 +31,7 @@ The authoritative package charter is the [`Target`]: crate::Target [`TargetSet`]: crate::TargetSet [`FetchRequest`]: crate::FetchRequest +[`FetchSelector`]: crate::source::FetchSelector [`DeliveryRequest`]: crate::DeliveryRequest [`EventSource`]: crate::EventSource [`EventSink`]: crate::EventSink @@ -59,7 +61,8 @@ select an async runtime or require an async-trait macro. - `status` is observational and must not begin fetch or delivery work. - `fetch` returns one bounded page plus per-target outcomes and explicit - continuation state. + continuation state. Returned events must satisfy the request selector; + request-bound page validation rejects adapter drift. - `deliver` returns one receipt for the exact request and every requested target; it never performs an implicit retry. - Implementations must not install an executor or spawn hidden workers. The diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -24,9 +24,14 @@ pub enum RadrootsTransportError { InvalidFetchRequestId, InvalidFetchLimit, InvalidFetchDeadline, + FetchSelectorTooLarge, + DuplicateFetchKind, + DuplicateFetchAuthor, + InvalidFetchTimeRange, EmptyFetchCursor, InvalidFetchCursor, InvalidObservedAt, + UnexpectedFetchEvent, UnexpectedFetchProvenance, UnexpectedFetchTargetOutcome, DuplicateFetchTargetOutcome, @@ -83,9 +88,24 @@ impl fmt::Display for RadrootsTransportError { Self::InvalidFetchRequestId => f.write_str("transport fetch request id is invalid"), Self::InvalidFetchLimit => f.write_str("transport fetch limit is invalid"), Self::InvalidFetchDeadline => f.write_str("transport fetch deadline is invalid"), + Self::FetchSelectorTooLarge => { + f.write_str("transport fetch selector exceeds its item limit") + } + Self::DuplicateFetchKind => { + f.write_str("transport fetch selector contains a duplicate event kind") + } + Self::DuplicateFetchAuthor => { + f.write_str("transport fetch selector contains a duplicate author") + } + Self::InvalidFetchTimeRange => { + f.write_str("transport fetch selector time range is invalid") + } Self::EmptyFetchCursor => f.write_str("transport fetch cursor is empty"), Self::InvalidFetchCursor => f.write_str("transport fetch cursor is invalid"), Self::InvalidObservedAt => f.write_str("transport event observation time is invalid"), + Self::UnexpectedFetchEvent => { + f.write_str("transport fetch page contains an event outside its selector") + } Self::UnexpectedFetchProvenance => { f.write_str("transport fetch page contains unexpected event provenance") } diff --git a/crates/transport/src/source.rs b/crates/transport/src/source.rs @@ -8,6 +8,7 @@ use crate::{ use alloc::{boxed::Box, collections::BTreeSet, string::String, vec::Vec}; use core::{fmt, future::Future, pin::Pin}; use radroots_event::SignedEvent; +use radroots_identity::PublicKey; pub use crate::status::SourceStatus; @@ -17,6 +18,10 @@ pub const FETCH_REQUEST_ID_MAX_BYTES: usize = 256; pub const FETCH_CURSOR_MAX_BYTES: usize = 2_048; /// Maximum number of events one page may request. pub const FETCH_PAGE_MAX_EVENTS: u16 = 1_000; +/// Maximum distinct event kinds in one source selector. +pub const FETCH_SELECTOR_MAX_KINDS: usize = 64; +/// Maximum distinct event authors in one source selector. +pub const FETCH_SELECTOR_MAX_AUTHORS: usize = 256; /// Heap-backed future returned by transport SPIs. pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; @@ -119,6 +124,111 @@ impl FetchBounds { } } +/// Transport-neutral constraints applied before a source page is bounded. +/// +/// An empty kind or author collection means "any" for that dimension. Time +/// bounds are inclusive Unix seconds. Adapters must apply every configured +/// dimension remotely when their protocol supports it and must defensively +/// exclude non-matching events before returning a page. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FetchSelector { + kinds: Vec<u32>, + authors: Vec<PublicKey>, + since_unix_seconds: Option<u64>, + until_unix_seconds: Option<u64>, +} + +impl FetchSelector { + /// Creates a selector that accepts every event within request bounds. + #[must_use] + pub const fn all() -> Self { + Self { + kinds: Vec::new(), + authors: Vec::new(), + since_unix_seconds: None, + until_unix_seconds: None, + } + } + + /// Restricts the selector to exact, unique event kinds. + pub fn with_kinds(mut self, mut kinds: Vec<u32>) -> Result<Self, Error> { + if kinds.len() > FETCH_SELECTOR_MAX_KINDS { + return Err(Error::FetchSelectorTooLarge); + } + kinds.sort_unstable(); + if kinds.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(Error::DuplicateFetchKind); + } + self.kinds = kinds; + Ok(self) + } + + /// Restricts the selector to exact, unique canonical authors. + pub fn with_authors(mut self, mut authors: Vec<PublicKey>) -> Result<Self, Error> { + if authors.len() > FETCH_SELECTOR_MAX_AUTHORS { + return Err(Error::FetchSelectorTooLarge); + } + authors.sort(); + if authors.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(Error::DuplicateFetchAuthor); + } + self.authors = authors; + Ok(self) + } + + /// Sets an inclusive lower event-time bound. + pub fn with_since_unix_seconds(mut self, since: u64) -> Result<Self, Error> { + if self.until_unix_seconds.is_some_and(|until| since > until) { + return Err(Error::InvalidFetchTimeRange); + } + self.since_unix_seconds = Some(since); + Ok(self) + } + + /// Sets an inclusive upper event-time bound. + pub fn with_until_unix_seconds(mut self, until: u64) -> Result<Self, Error> { + if self.since_unix_seconds.is_some_and(|since| since > until) { + return Err(Error::InvalidFetchTimeRange); + } + self.until_unix_seconds = Some(until); + Ok(self) + } + + /// Returns sorted exact event kinds, or an empty slice for any kind. + pub fn kinds(&self) -> &[u32] { + self.kinds.as_slice() + } + + /// Returns sorted exact authors, or an empty slice for any author. + pub fn authors(&self) -> &[PublicKey] { + self.authors.as_slice() + } + + /// Returns the inclusive lower event-time bound. + pub const fn since_unix_seconds(&self) -> Option<u64> { + self.since_unix_seconds + } + + /// Returns the inclusive upper event-time bound. + pub const fn until_unix_seconds(&self) -> Option<u64> { + self.until_unix_seconds + } + + /// Returns whether one canonical signed event satisfies every dimension. + #[must_use] + pub fn matches(&self, event: &SignedEvent) -> bool { + (self.kinds.is_empty() || self.kinds.binary_search(&event.kind()).is_ok()) + && (self.authors.is_empty() || self.authors.binary_search(event.pubkey()).is_ok()) + && self + .since_unix_seconds + .is_none_or(|since| event.created_at() >= since) + && self + .until_unix_seconds + .is_none_or(|until| event.created_at() <= until) + } +} + /// Bounded request for one page from one or more transport targets. #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, Eq, PartialEq)] @@ -127,6 +237,7 @@ pub struct FetchRequest { target_set: TargetSet, bounds: FetchBounds, cursor: Option<FetchCursor>, + selector: FetchSelector, } impl FetchRequest { @@ -141,6 +252,7 @@ impl FetchRequest { target_set, bounds, cursor: None, + selector: FetchSelector::all(), }) } @@ -151,6 +263,13 @@ impl FetchRequest { self } + /// Applies explicit transport-neutral event constraints. + #[must_use] + pub fn with_selector(mut self, selector: FetchSelector) -> Self { + self.selector = selector; + self + } + /// Returns the request identity. pub fn request_id(&self) -> &FetchRequestId { &self.request_id @@ -170,6 +289,11 @@ impl FetchRequest { pub fn cursor(&self) -> Option<&FetchCursor> { self.cursor.as_ref() } + + /// Returns the exact event constraints for this request. + pub const fn selector(&self) -> &FetchSelector { + &self.selector + } } /// Transport observation attached to one inbound event. @@ -278,6 +402,7 @@ pub struct FetchPage { request_id: FetchRequestId, target_set: TargetSet, limit: u16, + selector: FetchSelector, events: Vec<ObservedEvent>, target_outcomes: Vec<FetchTargetOutcome>, next_page: NextPage, @@ -295,6 +420,7 @@ impl FetchPage { request_id: request.request_id.clone(), target_set: request.target_set.clone(), limit: request.bounds.limit, + selector: request.selector.clone(), events, target_outcomes, next_page, @@ -313,6 +439,9 @@ impl FetchPage { } for observed in &self.events { + if !self.selector.matches(observed.event()) { + return Err(Error::UnexpectedFetchEvent); + } let provenance = observed.provenance(); let Some(target) = self .target_set @@ -351,6 +480,7 @@ impl FetchPage { if &self.request_id != request.request_id() || self.target_set != *request.target_set() || self.limit != request.bounds().limit() + || self.selector != *request.selector() { return Err(Error::FetchPageRequestMismatch); } @@ -465,6 +595,8 @@ mod serde_impl { target_set: TargetSet, bounds: FetchBounds, cursor: Option<FetchCursor>, + #[serde(default)] + selector: FetchSelector, } impl<'de> serde::Deserialize<'de> for FetchRequest { @@ -474,6 +606,7 @@ mod serde_impl { { let wire = FetchRequestWire::deserialize(deserializer)?; Self::new(wire.request_id, wire.target_set, wire.bounds) + .map(|request| request.with_selector(wire.selector)) .map(|request| match wire.cursor { Some(cursor) => request.with_cursor(cursor), None => request, @@ -482,6 +615,38 @@ mod serde_impl { } } + impl<'de> serde::Deserialize<'de> for FetchSelector { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + #[serde(default)] + kinds: Vec<u32>, + #[serde(default)] + authors: Vec<PublicKey>, + since_unix_seconds: Option<u64>, + until_unix_seconds: Option<u64>, + } + + let wire = Wire::deserialize(deserializer)?; + let selector = FetchSelector::all() + .with_kinds(wire.kinds) + .and_then(|selector| selector.with_authors(wire.authors)) + .and_then(|selector| match wire.since_unix_seconds { + Some(since) => selector.with_since_unix_seconds(since), + None => Ok(selector), + }) + .and_then(|selector| match wire.until_unix_seconds { + Some(until) => selector.with_until_unix_seconds(until), + None => Ok(selector), + }); + selector.map_err(serde::de::Error::custom) + } + } + #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct EventProvenanceWire { @@ -512,6 +677,8 @@ mod serde_impl { request_id: FetchRequestId, target_set: TargetSet, limit: u16, + #[serde(default)] + selector: FetchSelector, events: Vec<ObservedEvent>, target_outcomes: Vec<FetchTargetOutcome>, next_page: NextPage, @@ -527,6 +694,7 @@ mod serde_impl { request_id: wire.request_id, target_set: wire.target_set, limit: wire.limit, + selector: wire.selector, events: wire.events, target_outcomes: wire.target_outcomes, next_page: wire.next_page, diff --git a/crates/transport/tests/source_contract.rs b/crates/transport/tests/source_contract.rs @@ -13,7 +13,8 @@ use radroots_transport::{ outcome::{FetchTargetOutcome, FetchTargetState}, source::{ EventProvenance, FETCH_CURSOR_MAX_BYTES, FETCH_PAGE_MAX_EVENTS, FETCH_REQUEST_ID_MAX_BYTES, - FetchBounds, FetchCursor, NextPage, ObservedEvent, + FETCH_SELECTOR_MAX_AUTHORS, FETCH_SELECTOR_MAX_KINDS, FetchBounds, FetchCursor, + FetchSelector, NextPage, ObservedEvent, }, }; @@ -21,6 +22,66 @@ fn target(uri: &str) -> Target { Target::nostr_relay(uri).expect("nostr target") } +#[test] +fn fetch_selector_is_bounded_canonical_and_request_bound() { + let event = signed_event(); + let author = *event.pubkey(); + let selector = FetchSelector::all() + .with_kinds(vec![1, 0]) + .expect("kind selector") + .with_authors(vec![author]) + .expect("author selector") + .with_since_unix_seconds(1_700_000_000) + .expect("since") + .with_until_unix_seconds(1_700_000_100) + .expect("until"); + + assert_eq!(selector.kinds(), &[0, 1]); + assert_eq!(selector.authors(), &[author]); + assert!(selector.matches(&event)); + assert_eq!( + FetchSelector::all() + .with_kinds(vec![1, 1]) + .expect_err("duplicate kind"), + Error::DuplicateFetchKind + ); + assert_eq!( + FetchSelector::all() + .with_authors(vec![author, author]) + .expect_err("duplicate author"), + Error::DuplicateFetchAuthor + ); + assert_eq!( + FetchSelector::all() + .with_kinds(vec![0; FETCH_SELECTOR_MAX_KINDS + 1]) + .expect_err("too many kinds"), + Error::FetchSelectorTooLarge + ); + assert_eq!( + FetchSelector::all() + .with_authors(vec![author; FETCH_SELECTOR_MAX_AUTHORS + 1]) + .expect_err("too many authors"), + Error::FetchSelectorTooLarge + ); + assert_eq!( + FetchSelector::all() + .with_since_unix_seconds(2) + .and_then(|selector| selector.with_until_unix_seconds(1)) + .expect_err("reversed range"), + Error::InvalidFetchTimeRange + ); + + let targets = TargetSet::new(vec![target("wss://one.example")]).expect("targets"); + let selected = request(targets.clone(), 1).with_selector(selector); + let page = FetchPage::for_request(&selected, Vec::new(), Vec::new(), NextPage::Complete) + .expect("selected page"); + assert_eq!( + page.validate_for_request(&request(targets, 1)) + .expect_err("selector mismatch"), + Error::FetchPageRequestMismatch + ); +} + fn signed_event() -> SignedEvent { let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; let wire = Nip01EventWire::parse_json(raw).expect("wire event"); diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -81,10 +81,12 @@ check before handing control to another network boundary. ## Fetch, delivery, and outcome behavior -Fetch accepts only configured Nostr targets, applies the request page bound, -deduplicates events by event ID, preserves per-relay provenance, and emits an -opaque versioned cursor when more results remain. Malformed relay events are -ignored and reported as a partial target outcome rather than admitted. +Fetch accepts only configured Nostr targets, translates transport-neutral kind, +author, and event-time selectors into Nostr filters, reapplies those selectors +defensively, applies the request page bound, deduplicates events by event ID, +preserves per-relay provenance, and emits an opaque versioned cursor when more +results remain. Malformed relay events are ignored and reported as a partial +target outcome rather than admitted. Delivery converts an already validated signed Radroots event to Nostr, attempts each configured target once, and returns one normalized receipt entry per diff --git a/crates/transport_nostr/src/source.rs b/crates/transport_nostr/src/source.rs @@ -3,7 +3,7 @@ use crate::{NostrTransport, RelayUrl, status}; use core::cmp::Ordering; use core::time::Duration; -use nostr_sdk::prelude::{Filter, JsonUtil, Timestamp}; +use nostr_sdk::prelude::{Filter, JsonUtil, Kind, Timestamp}; use radroots_transport::{ BoxFuture, EventSource, FetchPage, FetchRequest, outcome::{FetchTargetOutcome, FetchTargetState}, @@ -18,6 +18,7 @@ const CURSOR_PREFIX: &str = "nostr-v1"; #[derive(Clone, Debug)] pub(crate) struct SourceQuery { relays: Vec<RelayUrl>, + selector: radroots_transport::source::FetchSelector, until_unix_seconds: Option<u64>, connect_timeout: Duration, timeout: Duration, @@ -58,11 +59,39 @@ impl RelaySourceClient for LiveRelaySourceClient { for relay in query.relays { let url = relay.as_str().to_owned(); let result = async { + let kinds = query + .selector + .kinds() + .iter() + .filter_map(|kind| u16::try_from(*kind).ok()) + .map(Kind::from) + .collect::<Vec<_>>(); + if !query.selector.kinds().is_empty() && kinds.is_empty() { + return Ok(Vec::new()); + } + let authors = query + .selector + .authors() + .iter() + .filter_map(|author| radroots_nostr::key::public_key_to_nostr(*author).ok()) + .collect::<Vec<_>>(); + if authors.len() != query.selector.authors().len() { + return Ok(Vec::new()); + } self.client.add_relay(url.as_str()).await?; self.client .try_connect_relay(url.as_str(), query.connect_timeout) .await?; let mut filter = Filter::new().limit(UPSTREAM_FETCH_LIMIT); + if !kinds.is_empty() { + filter = filter.kinds(kinds); + } + if !authors.is_empty() { + filter = filter.authors(authors); + } + if let Some(since) = query.selector.since_unix_seconds() { + filter = filter.since(Timestamp::from_secs(since)); + } if let Some(until) = query.until_unix_seconds { filter = filter.until(Timestamp::from_secs(until)); } @@ -100,6 +129,7 @@ impl EventSource for NostrTransport { ) -> BoxFuture<'_, Result<FetchPage, radroots_transport::Error>> { Box::pin(async move { let cursor = request.cursor().map(parse_cursor).transpose()?.flatten(); + let selector_until = request.selector().until_unix_seconds(); let now_ms = unix_time_ms(); let remaining_ms = request.bounds().deadline_unix_ms().saturating_sub(now_ms); let timeout_ms = remaining_ms.min(self.config().request_timeout_ms()); @@ -137,7 +167,13 @@ impl EventSource for NostrTransport { .source_client .fetch(SourceQuery { relays: targets.keys().cloned().collect(), - until_unix_seconds: cursor.as_ref().map(|cursor| cursor.created_at), + selector: request.selector().clone(), + until_unix_seconds: match (selector_until, cursor.as_ref()) { + (Some(until), Some(cursor)) => Some(until.min(cursor.created_at)), + (Some(until), None) => Some(until), + (None, Some(cursor)) => Some(cursor.created_at), + (None, None) => None, + }, connect_timeout: Duration::from_millis( timeout_ms.min(self.config().connect_timeout_ms()), ), @@ -162,12 +198,15 @@ impl EventSource for NostrTransport { succeeded += 1; for raw in raw_events { match radroots_event_codec::decode::signed_event(raw.as_str()) { - Ok(event) => candidates.push(Candidate { - relay: batch.relay.clone(), - created_at: event.created_at(), - event_id: event.id_str().to_owned(), - raw, - }), + Ok(event) if request.selector().matches(&event) => { + candidates.push(Candidate { + relay: batch.relay.clone(), + created_at: event.created_at(), + event_id: event.id_str().to_owned(), + raw, + }) + } + Ok(_) => {} Err(_) => { *malformed_by_relay.entry(batch.relay.clone()).or_default() += 1; @@ -311,7 +350,7 @@ mod tests { use crate::{Config, RelayUrlPolicy}; use radroots_transport::{ FetchRequest, Target, TargetSet, - source::{FetchBounds, NextPage}, + source::{FetchBounds, FetchSelector, NextPage}, }; use std::sync::{ Arc, @@ -390,6 +429,31 @@ mod tests { } #[test] + fn source_applies_kind_author_and_time_selectors_before_page_bounds() { + let event = radroots_event_codec::decode::signed_event(FIRST).expect("fixture event"); + let selector = FetchSelector::all() + .with_kinds(vec![0]) + .expect("kind") + .with_authors(vec![*event.pubkey()]) + .expect("author") + .with_since_unix_seconds(1_750_000_000) + .expect("since"); + let selected = + futures::executor::block_on(transport().fetch(request(10).with_selector(selector))) + .expect("selected page"); + assert_eq!(selected.events().len(), 1); + assert_eq!(selected.events()[0].event().id_str(), event.id_str()); + + let excluded = FetchSelector::all() + .with_kinds(vec![1]) + .expect("excluded kind"); + let selected = + futures::executor::block_on(transport().fetch(request(10).with_selector(excluded))) + .expect("empty selected page"); + assert!(selected.events().is_empty()); + } + + #[test] fn malformed_cursor_fails_before_relay_access() { let request = request(1).with_cursor(FetchCursor::parse("other:1:value").expect("opaque")); let error = futures::executor::block_on(transport().fetch(request)).expect_err("cursor"); diff --git a/docs/api/radroots_transport.txt b/docs/api/radroots_transport.txt @@ -95,6 +95,8 @@ pub radroots_transport::error::RadrootsTransportError::DeliveryReceiptTargetSetM 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 @@ -112,6 +114,7 @@ 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 @@ -120,6 +123,7 @@ 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 @@ -137,6 +141,7 @@ pub radroots_transport::error::RadrootsTransportError::RequiredTargetNotRequeste 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 @@ -199,11 +204,15 @@ impl radroots_transport::policy::SatisfactionPolicy pub const fn radroots_transport::policy::SatisfactionPolicy::class(&self) -> radroots_transport::policy::SatisfactionClass pub const fn radroots_transport::policy::SatisfactionPolicy::new(radroots_transport::policy::SatisfactionClass, radroots_transport::policy::TargetPolicy) -> Self pub const fn radroots_transport::policy::SatisfactionPolicy::targets(&self) -> &radroots_transport::policy::TargetPolicy +pub fn radroots_transport::policy::SatisfactionPolicy::validate_for(&self, &radroots_transport::target::TargetSet) -> core::result::Result<(), radroots_transport::error::Error> pub struct radroots_transport::policy::TargetPolicy impl radroots_transport::policy::TargetPolicy pub const fn radroots_transport::policy::TargetPolicy::all() -> Self pub const fn radroots_transport::policy::TargetPolicy::any() -> Self +pub const fn radroots_transport::policy::TargetPolicy::is_all(&self) -> bool +pub const fn radroots_transport::policy::TargetPolicy::is_any(&self) -> bool pub const fn radroots_transport::policy::TargetPolicy::quorum(u16) -> core::result::Result<Self, radroots_transport::error::Error> +pub const fn radroots_transport::policy::TargetPolicy::quorum_threshold(&self) -> core::option::Option<u16> pub fn radroots_transport::policy::TargetPolicy::required(alloc::vec::Vec<radroots_transport::target::TargetFingerprint>) -> core::result::Result<Self, radroots_transport::error::Error> 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 @@ -313,8 +322,10 @@ pub const fn radroots_transport::source::FetchRequest::bounds(&self) -> radroots pub fn radroots_transport::source::FetchRequest::cursor(&self) -> core::option::Option<&radroots_transport::source::FetchCursor> pub fn radroots_transport::source::FetchRequest::new(impl core::convert::Into<alloc::string::String>, radroots_transport::target::TargetSet, radroots_transport::source::FetchBounds) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::source::FetchRequest::request_id(&self) -> &radroots_transport::source::FetchRequestId +pub const fn radroots_transport::source::FetchRequest::selector(&self) -> &radroots_transport::source::FetchSelector pub fn radroots_transport::source::FetchRequest::target_set(&self) -> &radroots_transport::target::TargetSet pub fn radroots_transport::source::FetchRequest::with_cursor(self, radroots_transport::source::FetchCursor) -> Self +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::source::FetchRequestId(_) @@ -327,6 +338,20 @@ impl serde_core::ser::Serialize for radroots_transport::source::FetchRequestId pub fn radroots_transport::source::FetchRequestId::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::source::FetchRequestId pub fn radroots_transport::source::FetchRequestId::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::source::FetchSelector +impl radroots_transport::source::FetchSelector +pub const fn radroots_transport::source::FetchSelector::all() -> Self +pub fn radroots_transport::source::FetchSelector::authors(&self) -> &[radroots_identity::key::PublicKey] +pub fn radroots_transport::source::FetchSelector::kinds(&self) -> &[u32] +pub fn radroots_transport::source::FetchSelector::matches(&self, &radroots_event::draft::SignedEvent) -> bool +pub const fn radroots_transport::source::FetchSelector::since_unix_seconds(&self) -> core::option::Option<u64> +pub const fn radroots_transport::source::FetchSelector::until_unix_seconds(&self) -> core::option::Option<u64> +pub fn radroots_transport::source::FetchSelector::with_authors(self, alloc::vec::Vec<radroots_identity::key::PublicKey>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::source::FetchSelector::with_kinds(self, alloc::vec::Vec<u32>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::source::FetchSelector::with_since_unix_seconds(self, u64) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::source::FetchSelector::with_until_unix_seconds(self, u64) -> core::result::Result<Self, radroots_transport::error::Error> +impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::source::FetchSelector +pub fn radroots_transport::source::FetchSelector::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::source::ObservedEvent impl radroots_transport::source::ObservedEvent pub const fn radroots_transport::source::ObservedEvent::event(&self) -> &radroots_event::draft::SignedEvent @@ -345,6 +370,8 @@ pub const fn radroots_transport::SourceStatus::transport_id(&self) -> radroots_t pub const radroots_transport::source::FETCH_CURSOR_MAX_BYTES: usize pub const radroots_transport::source::FETCH_PAGE_MAX_EVENTS: u16 pub const radroots_transport::source::FETCH_REQUEST_ID_MAX_BYTES: usize +pub const radroots_transport::source::FETCH_SELECTOR_MAX_AUTHORS: usize +pub const radroots_transport::source::FETCH_SELECTOR_MAX_KINDS: usize pub trait radroots_transport::source::EventSource: core::marker::Send + core::marker::Sync pub fn radroots_transport::source::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::source::EventSource::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::SourceStatus, radroots_transport::error::Error>> @@ -464,6 +491,8 @@ 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 @@ -481,6 +510,7 @@ 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 @@ -489,6 +519,7 @@ 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 @@ -506,6 +537,7 @@ 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 @@ -641,8 +673,10 @@ pub const fn radroots_transport::source::FetchRequest::bounds(&self) -> radroots pub fn radroots_transport::source::FetchRequest::cursor(&self) -> core::option::Option<&radroots_transport::source::FetchCursor> pub fn radroots_transport::source::FetchRequest::new(impl core::convert::Into<alloc::string::String>, radroots_transport::target::TargetSet, radroots_transport::source::FetchBounds) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::source::FetchRequest::request_id(&self) -> &radroots_transport::source::FetchRequestId +pub const fn radroots_transport::source::FetchRequest::selector(&self) -> &radroots_transport::source::FetchSelector pub fn radroots_transport::source::FetchRequest::target_set(&self) -> &radroots_transport::target::TargetSet pub fn radroots_transport::source::FetchRequest::with_cursor(self, radroots_transport::source::FetchCursor) -> Self +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 diff --git a/docs/implementation/DEVIATIONS.md b/docs/implementation/DEVIATIONS.md @@ -17,6 +17,7 @@ silently change `radroots.crates.release.v1`. | `RCRV1-DEV-008` | 153, 155, 171, 179, 226, 288, 293, 313 | Activate final secrets dependency edges now; quarantine legacy vault/store consumers until their ordered storage, SDK, downstream, and final-removal gates. | | `RCRV1-DEV-009` | 170, 179, 189, 196, 201, 213, 226, 235, 263, 269, 288, 292, 313 | Quarantine the four superseded storage packages until their independently buildable first-party consumers migrate, then remove them at Step 313. | | `RCRV1-DEV-011` | 225, 226, 248 | Quarantine the superseded geocoder package until the standalone SDK adopts `radroots_geonames`, then remove it at the SDK retirement gate. | +| `RCRV1-DEV-012` | 279, 282-283 | Complete the mobile shared-engine cutover with bounded transport selectors and SDK-owned signing/Nostr composition before qualifying generated host artifacts. | ## Record template diff --git a/docs/implementation/deviations.toml b/docs/implementation/deviations.toml @@ -249,3 +249,29 @@ verification = [ unresolved_risk = "The standalone SDK remains coupled to the predecessor API until its ordered cutover; package-realistic publication is blocked until Step 248 removes the bridge." normative_architecture_change = false adr_required = false + +[[deviation]] +id = "RCRV1-DEV-012" +date = "2026-08-03" +status = "active" +approval = "All code-review recommendations and the full multi-RCLD implementation sequence were explicitly approved by the user." +affected_steps = ["279", "282", "283"] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#9-radroots_transport", + "docs/specs/radroots_crates_release_v1.md#15-radroots_transport_nostr", + "docs/specs/radroots_crates_release_v1.md#18-radroots_sdk", +] +source_evidence = [ + "The first-party mobile compile gate proved that its real relay-backed identity, profile, and post operations had no equivalent after the initial shared-engine bridge cutover.", + "The generic source request could bound pages and targets but could not express event kind, author, or event-time constraints required for correct profile and feed queries.", + "Restoring the retired parallel runtime or accepting client-side filtering after page truncation would violate the final SDK and transport ownership model.", +] +replacement_action = "Before qualifying the mobile artifact, add bounded transport-neutral fetch selectors, translate them in the concrete Nostr adapter, complete SDK-owned explicit local-signing and Nostr composition, and map the mobile presentation contract over those SDK operations without restoring direct lower-package dependencies." +verification = [ + "Transport selector construction rejects oversized, duplicate, and reversed-range inputs and binds selectors into page validation.", + "The Nostr adapter applies kind, author, and time constraints remotely and defensively filters returned events before page bounds.", + "The shared-engine SDK and mobile integration gates must pass before Steps 282 and 283 are marked complete.", +] +unresolved_risk = "Steps 282 and 283 remain blocked until the SDK and mobile bridge checkpoints implement and verify the complete operation surface." +normative_architecture_change = false +adr_required = false