lib

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

commit 858b9e0d4fc25c3bc53ca9550c898fe8148c1bd8
parent 7acb2c09a64014786d635b0fbfd101460b73aaa3
Author: triesap <tyson@radroots.org>
Date:   Mon, 21 Sep 2026 09:29:20 +0000

transport_nostr: preserve terminal quota refusals

- Normalize explicit quota exhaustion with a stable redacted code
- Suppress unchanged-request retries through existing typed outcomes
- Cover real relay receipts and distinct normalized failure classes
- Verify package coverage and complete owner workspace contracts

Diffstat:
Mcontracts/architecture/decisions/nostr_exact_delivery.v1.json | 1+
Mcrates/transport_nostr/README.md | 8+++++++-
Mcrates/transport_nostr/src/status.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/transport_nostr/tests/exact_delivery.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 104 insertions(+), 3 deletions(-)

diff --git a/contracts/architecture/decisions/nostr_exact_delivery.v1.json b/contracts/architecture/decisions/nostr_exact_delivery.v1.json @@ -9,6 +9,7 @@ "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.", + "quota_evidence": "An explicit quota refusal is a terminal Rejected outcome with stable quota_exceeded code and a redacted message. It suppresses unchanged-request retries; resolving quota and authorizing further work belong to the host. Rate limits, authentication and unknown failures keep their distinct existing classifications. No remote acceptance, rollback or erasure is inferred.", "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 @@ -193,10 +193,16 @@ stable repeated terminal results follow the generic subscription contract. 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 +requirements, quota refusals, 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. +An explicit quota refusal has the stable `quota_exceeded` code, a redacted +message and terminal rejection classification. It suppresses retries of the +unchanged request. Resolving the quota and authorizing further work belong to +the host; the adapter does not discard durable intent or recorded effects. +Rate limiting remains distinct from quota exhaustion. + Source and sink status are passive in-memory observations. A configured relay starts unobserved and never appears available before successful read or write evidence. Read and write evidence, failure counters, retry classes, and diff --git a/crates/transport_nostr/src/status.rs b/crates/transport_nostr/src/status.rs @@ -15,6 +15,7 @@ enum FailureClass { Duplicate, Rejected, AuthRequired, + Quota, RateLimited, Timeout, Connection, @@ -28,6 +29,7 @@ impl FailureClass { Self::Duplicate => "duplicate", Self::Rejected => "rejected", Self::AuthRequired => "auth_required", + Self::Quota => "quota_exceeded", Self::RateLimited => "rate_limited", Self::Timeout => "timeout", Self::Connection => "connection_failed", @@ -41,6 +43,7 @@ impl FailureClass { Self::Duplicate => "relay already has the event", Self::Rejected => "relay rejected the event", Self::AuthRequired => "relay authentication is required", + Self::Quota => "relay quota was exhausted", Self::RateLimited => "relay rate limit was reached", Self::Timeout => "relay operation timed out", Self::Connection => "relay connection failed", @@ -580,7 +583,9 @@ pub(crate) fn delivery_failure(upstream: &str) -> DeliveryOutcome { let class = classify(upstream).class; let outcome = match class { FailureClass::Duplicate => DeliveryOutcome::accepted(), - FailureClass::Rejected | FailureClass::Malformed => DeliveryOutcome::rejected(), + FailureClass::Rejected | FailureClass::Malformed | FailureClass::Quota => { + DeliveryOutcome::rejected() + } FailureClass::AuthRequired => DeliveryOutcome::failed(Retryability::Retryable) .expect("retryable authentication outcome"), FailureClass::RateLimited @@ -596,7 +601,9 @@ pub(crate) fn delivery_failure(upstream: &str) -> DeliveryOutcome { pub(crate) fn fetch_failure(upstream: &str) -> (FetchTargetState, &'static str) { let class = classify(upstream).class; let state = match class { - FailureClass::Rejected | FailureClass::Malformed => FetchTargetState::FailedTerminal, + FailureClass::Rejected | FailureClass::Malformed | FailureClass::Quota => { + FetchTargetState::FailedTerminal + } _ => FetchTargetState::FailedRetryable, }; (state, class.message()) @@ -608,6 +615,8 @@ fn classify(upstream: &str) -> RedactedDiagnostic { FailureClass::Duplicate } else if message.contains("auth") { FailureClass::AuthRequired + } else if message.contains("quota") { + FailureClass::Quota } else if message.contains("blocked") || message.contains("restricted") || message.contains("invalid") @@ -662,6 +671,8 @@ mod tests { ("duplicate: already have", "duplicate"), ("blocked by policy", "rejected"), ("auth required", "auth_required"), + ("quota exceeded", "quota_exceeded"), + ("blocked: account QUOTA exhausted", "quota_exceeded"), ("rate limited", "rate_limited"), ("connection timeout", "timeout"), ("connection offline", "connection_failed"), @@ -677,6 +688,41 @@ mod tests { } #[test] + fn quota_refusal_requires_action_without_reclassifying_other_failures() { + for text in ["quota exceeded", "blocked: account QUOTA exhausted"] { + let outcome = delivery_failure(text); + assert_eq!(outcome.code(), Some("quota_exceeded")); + assert_eq!(outcome.kind(), DeliveryOutcomeKind::Rejected); + assert_eq!(outcome.retryability(), Retryability::Terminal); + assert_eq!( + fetch_failure(text), + ( + FetchTargetState::FailedTerminal, + "relay quota was exhausted" + ) + ); + } + assert_eq!( + delivery_failure("rate limited").code(), + Some("rate_limited") + ); + assert!(delivery_failure("rate limited").is_retryable()); + assert_eq!( + delivery_failure("auth required").code(), + Some("auth_required") + ); + assert_eq!( + delivery_failure("malformed event").code(), + Some("malformed_event") + ); + assert_eq!( + delivery_failure("unknown failure").code(), + Some("relay_failure") + ); + assert!(delivery_failure("unknown failure").is_retryable()); + } + + #[test] fn status_requires_directional_evidence_and_backoff_is_monotonic() { let (tracker, canonical, writable) = tracker(); let initial = tracker.report(); diff --git a/crates/transport_nostr/tests/exact_delivery.rs b/crates/transport_nostr/tests/exact_delivery.rs @@ -213,6 +213,54 @@ async fn rejection_lost_ack_and_disconnect_never_invent_acceptance() { } #[tokio::test] +async fn quota_refusal_is_terminal_and_redacted_at_the_receipt_boundary() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let config = config(&format!("ws://{}", listener.local_addr().unwrap()), 2_000); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(tcp).await.unwrap(); + let event: Value = serde_json::from_str(&text(&mut socket).await).unwrap(); + reply( + &mut socket, + json!([ + "OK", + event[1]["id"], + false, + "quota exceeded: private-account-detail" + ]), + ) + .await; + }); + let transport = NostrTransport::new(config.clone()); + let receipt = transport + .deliver(request(&config, &raw_event())) + .await + .unwrap(); + let target = &receipt.target_receipts()[0]; + assert!(target.was_attempted()); + assert_eq!(target.outcome().code(), Some("quota_exceeded")); + assert_eq!( + target.outcome().retryability(), + radroots_transport::outcome::Retryability::Terminal + ); + assert_eq!( + target.outcome().kind(), + radroots_transport::outcome::DeliveryOutcomeKind::Rejected + ); + assert_eq!( + target.outcome().message(), + Some("relay quota was exhausted") + ); + assert_eq!( + transport.relay_status().relays()[0] + .write() + .last_failure_retryable(), + Some(false) + ); + server.await.unwrap(); +} + +#[tokio::test] async fn queued_delivery_targets_cannot_start_after_the_shared_deadline() { let first = TcpListener::bind("127.0.0.1:0").await.unwrap(); let second = TcpListener::bind("127.0.0.1:0").await.unwrap();