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