lib

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

commit 742246bc06d734a17c0a6d43551432c2c7eb4ba9
parent c97e431c09eec9a2e1d964fdb16e66301c06ecab
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 10:04:46 +0000

transport: enforce coherent outcome authority

- derive outcome codes, target statuses, and retry classes from typed kinds
- map Nostr authentication and proof-of-work responses to retryable challenges
- reject incoherent and unknown extension codes before transport execution
- govern the exact core and relay outcome inventories with checked-in vectors

Diffstat:
Acontracts/conformance/vectors/transport/outcome_matrix.v1.json | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/src/error.rs | 8++++++++
Mcrates/transport/src/lib.rs | 2+-
Mcrates/transport/src/status.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/transport/tests/source_boundary.rs | 4++++
Mcrates/transport/tests/transport.rs | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/transport_nostr/src/outbox.rs | 105+++++++++++--------------------------------------------------------------------
Mcrates/transport_nostr/src/outcome.rs | 11+++++++----
Mcrates/transport_nostr/tests/transport.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/transport_reticulum/src/lib.rs | 11++---------
Mcrates/transport_reticulum/tests/reticulum.rs | 21+++++++++------------
11 files changed, 568 insertions(+), 192 deletions(-)

diff --git a/contracts/conformance/vectors/transport/outcome_matrix.v1.json b/contracts/conformance/vectors/transport/outcome_matrix.v1.json @@ -0,0 +1,210 @@ +{ + "suite": "transport_outcome_matrix", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "transport_outcome_accepted_001", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Accepted" }, + "expected": { "code": "accepted", "target_status": "Accepted", "retry_class": "None" } + }, + { + "id": "transport_outcome_duplicate_accepted_002", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "DuplicateAccepted" }, + "expected": { "code": "duplicate_accepted", "target_status": "Accepted", "retry_class": "None" } + }, + { + "id": "transport_outcome_delivered_003", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Delivered" }, + "expected": { "code": "delivered", "target_status": "Delivered", "retry_class": "None" } + }, + { + "id": "transport_outcome_forwarded_004", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Forwarded" }, + "expected": { "code": "forwarded", "target_status": "Forwarded", "retry_class": "None" } + }, + { + "id": "transport_outcome_stored_by_gateway_005", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "StoredByGateway" }, + "expected": { "code": "stored_by_gateway", "target_status": "StoredByGateway", "retry_class": "None" } + }, + { + "id": "transport_outcome_seen_006", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Seen" }, + "expected": { "code": "seen", "target_status": "Seen", "retry_class": "None" } + }, + { + "id": "transport_outcome_deferred_007", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "DeferredUntilImplemented" }, + "expected": { "code": "deferred_until_implemented", "target_status": "DeferredUntilImplemented", "retry_class": "DeferredUntilImplemented" } + }, + { + "id": "transport_outcome_rejected_008", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Rejected" }, + "expected": { "code": "rejected", "target_status": "FailedTerminal", "retry_class": "Terminal" } + }, + { + "id": "transport_outcome_route_unavailable_009", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "RouteUnavailable" }, + "expected": { "code": "route_unavailable", "target_status": "FailedTerminal", "retry_class": "Terminal" } + }, + { + "id": "transport_outcome_payload_too_large_010", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "PayloadTooLarge" }, + "expected": { "code": "payload_too_large", "target_status": "FailedTerminal", "retry_class": "Terminal" } + }, + { + "id": "transport_outcome_policy_denied_011", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "PolicyDenied" }, + "expected": { "code": "policy_denied", "target_status": "SkippedPolicyDenied", "retry_class": "Terminal" } + }, + { + "id": "transport_outcome_challenge_required_012", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "ChallengeRequired" }, + "expected": { "code": "challenge_required", "target_status": "FailedRetryable", "retry_class": "Retryable" } + }, + { + "id": "transport_outcome_timeout_013", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "Timeout" }, + "expected": { "code": "timeout", "target_status": "FailedRetryable", "retry_class": "Retryable" } + }, + { + "id": "transport_outcome_connection_failed_014", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "ConnectionFailed" }, + "expected": { "code": "connection_failed", "target_status": "FailedRetryable", "retry_class": "Retryable" } + }, + { + "id": "transport_outcome_transport_unavailable_015", + "kind": "transport.outcome_matrix.core", + "input": { "outcome_kind": "TransportUnavailable" }, + "expected": { "code": "transport_unavailable", "target_status": "FailedRetryable", "retry_class": "Retryable" } + }, + { + "id": "nostr_outcome_accepted_016", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Accepted" }, + "expected": { "code": "accepted", "transport_kind": "Accepted", "retryable": false, "terminal": false, "counts_toward_quorum": true } + }, + { + "id": "nostr_outcome_duplicate_accepted_017", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "DuplicateAccepted" }, + "expected": { "code": "duplicate_accepted", "transport_kind": "DuplicateAccepted", "retryable": false, "terminal": false, "counts_toward_quorum": true } + }, + { + "id": "nostr_outcome_blocked_018", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Blocked" }, + "expected": { "code": "blocked", "transport_kind": "Rejected", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_rate_limited_019", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "RateLimited" }, + "expected": { "code": "rate_limited", "transport_kind": "TransportUnavailable", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_invalid_020", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Invalid" }, + "expected": { "code": "invalid", "transport_kind": "Rejected", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_pow_required_021", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "PowRequired" }, + "expected": { "code": "pow_required", "transport_kind": "ChallengeRequired", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_restricted_022", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Restricted" }, + "expected": { "code": "restricted", "transport_kind": "Rejected", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_auth_required_023", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "AuthRequired" }, + "expected": { "code": "auth_required", "transport_kind": "ChallengeRequired", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_challenge_required_024", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "ChallengeRequired" }, + "expected": { "code": "challenge_required", "transport_kind": "ChallengeRequired", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_muted_025", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Muted" }, + "expected": { "code": "muted", "transport_kind": "Rejected", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_unsupported_026", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Unsupported" }, + "expected": { "code": "unsupported", "transport_kind": "Rejected", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_payment_required_027", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "PaymentRequired" }, + "expected": { "code": "payment_required", "transport_kind": "PolicyDenied", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_error_028", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Error" }, + "expected": { "code": "error", "transport_kind": "TransportUnavailable", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_timeout_029", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Timeout" }, + "expected": { "code": "timeout", "transport_kind": "Timeout", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_connection_failed_030", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "ConnectionFailed" }, + "expected": { "code": "connection_failed", "transport_kind": "ConnectionFailed", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_relay_url_rejected_031", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "RelayUrlRejected" }, + "expected": { "code": "relay_url_rejected", "transport_kind": "RouteUnavailable", "retryable": false, "terminal": true, "counts_toward_quorum": false } + }, + { + "id": "nostr_outcome_skipped_already_accepted_032", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "SkippedAlreadyAccepted" }, + "expected": { "code": "skipped_already_accepted", "transport_kind": "DuplicateAccepted", "retryable": false, "terminal": false, "counts_toward_quorum": true } + }, + { + "id": "nostr_outcome_unknown_033", + "kind": "transport.outcome_matrix.nostr_relay", + "input": { "relay_kind": "Unknown" }, + "expected": { "code": "unknown", "transport_kind": "TransportUnavailable", "retryable": true, "terminal": false, "counts_toward_quorum": false } + }, + { + "id": "transport_outcome_extension_code_policy_034", + "kind": "transport.outcome_matrix.extension_code_policy", + "input": { "outcome_kind": "Accepted", "code": "vendor_retryable" }, + "expected": { "result": "reject_before_transport_execution" } + } + ] +} diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -27,6 +27,8 @@ pub enum RadrootsTransportError { DeliveryTargetReceiptStatusMismatch, DeliveryTargetReceiptAttemptMismatch, TransportOutcomeStatusMismatch, + TransportOutcomeCodeMismatch, + TransportOutcomeRetryClassMismatch, DeliveryReceiptRequestIdMismatch, DeliveryReceiptTargetSetMismatch, EmptyFetchRequestId, @@ -106,6 +108,12 @@ impl fmt::Display for RadrootsTransportError { Self::TransportOutcomeStatusMismatch => { f.write_str("transport outcome status does not match its outcome kind") } + Self::TransportOutcomeCodeMismatch => { + f.write_str("transport outcome code does not match its outcome kind") + } + Self::TransportOutcomeRetryClassMismatch => { + f.write_str("transport outcome retry class does not match its outcome kind") + } Self::DeliveryReceiptRequestIdMismatch => { f.write_str("transport delivery receipt request id does not match its request") } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -52,7 +52,7 @@ pub use reticulum::{ }; pub use status::{ RadrootsTransportCapabilities, RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportStatus, + RadrootsTransportOutcomeKind, RadrootsTransportRetryClass, RadrootsTransportStatus, }; pub use target::{ RADROOTS_TRANSPORT_TARGET_FINGERPRINT_BYTES, RadrootsTransportMeshScopeId, diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -86,6 +86,7 @@ pub enum RadrootsTransportOutcomeKind { RouteUnavailable, PayloadTooLarge, PolicyDenied, + ChallengeRequired, Timeout, ConnectionFailed, TransportUnavailable, @@ -105,6 +106,7 @@ impl RadrootsTransportOutcomeKind { Self::RouteUnavailable => "route_unavailable", Self::PayloadTooLarge => "payload_too_large", Self::PolicyDenied => "policy_denied", + Self::ChallengeRequired => "challenge_required", Self::Timeout => "timeout", Self::ConnectionFailed => "connection_failed", Self::TransportUnavailable => "transport_unavailable", @@ -124,9 +126,10 @@ impl RadrootsTransportOutcomeKind { RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented } Self::PolicyDenied => RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied, - Self::Timeout | Self::ConnectionFailed | Self::TransportUnavailable => { - RadrootsTransportDeliveryTargetStatus::FailedRetryable - } + Self::ChallengeRequired + | Self::Timeout + | Self::ConnectionFailed + | Self::TransportUnavailable => RadrootsTransportDeliveryTargetStatus::FailedRetryable, Self::Rejected | Self::RouteUnavailable | Self::PayloadTooLarge => { RadrootsTransportDeliveryTargetStatus::FailedTerminal } @@ -139,6 +142,46 @@ impl RadrootsTransportOutcomeKind { ) -> bool { self.target_status().counts_as_satisfied(satisfaction_class) } + + pub fn retry_class(self) -> RadrootsTransportRetryClass { + match self { + Self::Accepted + | Self::DuplicateAccepted + | Self::Delivered + | Self::Forwarded + | Self::StoredByGateway + | Self::Seen => RadrootsTransportRetryClass::None, + Self::DeferredUntilImplemented => RadrootsTransportRetryClass::DeferredUntilImplemented, + Self::ChallengeRequired + | Self::Timeout + | Self::ConnectionFailed + | Self::TransportUnavailable => RadrootsTransportRetryClass::Retryable, + Self::Rejected + | Self::RouteUnavailable + | Self::PayloadTooLarge + | Self::PolicyDenied => RadrootsTransportRetryClass::Terminal, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsTransportRetryClass { + None, + Retryable, + Terminal, + DeferredUntilImplemented, +} + +impl RadrootsTransportRetryClass { + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Retryable => "retryable", + Self::Terminal => "terminal", + Self::DeferredUntilImplemented => "deferred_until_implemented", + } + } } #[cfg_attr(feature = "serde", derive(serde::Serialize))] @@ -146,7 +189,8 @@ impl RadrootsTransportOutcomeKind { pub struct RadrootsTransportOutcome { kind: RadrootsTransportOutcomeKind, status: RadrootsTransportDeliveryTargetStatus, - code: Option<String>, + code: String, + retry_class: RadrootsTransportRetryClass, message: Option<String>, } @@ -155,7 +199,8 @@ impl RadrootsTransportOutcome { Self { kind, status: kind.target_status(), - code: None, + code: String::from(kind.as_str()), + retry_class: kind.retry_class(), message: None, } } @@ -174,20 +219,6 @@ impl RadrootsTransportOutcome { Ok(self) } - pub fn try_with_code( - mut self, - code: impl Into<String>, - ) -> Result<Self, crate::RadrootsTransportError> { - let code = code.into(); - crate::limits::ensure_resource_limit( - "transport_outcome_code", - code.len(), - RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, - )?; - self.code = Some(code); - Ok(self) - } - pub fn kind(&self) -> RadrootsTransportOutcomeKind { self.kind } @@ -196,8 +227,16 @@ impl RadrootsTransportOutcome { self.status } - pub fn code(&self) -> Option<&str> { - self.code.as_deref() + pub fn code(&self) -> &str { + self.code.as_str() + } + + pub const fn retry_class(&self) -> RadrootsTransportRetryClass { + self.retry_class + } + + pub const fn is_retryable(&self) -> bool { + matches!(self.retry_class, RadrootsTransportRetryClass::Retryable) } pub fn message(&self) -> Option<&str> { @@ -208,12 +247,16 @@ impl RadrootsTransportOutcome { if self.status != self.kind.target_status() { return Err(crate::RadrootsTransportError::TransportOutcomeStatusMismatch); } - if let Some(code) = &self.code { - crate::limits::ensure_resource_limit( - "transport_outcome_code", - code.len(), - RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, - )?; + crate::limits::ensure_resource_limit( + "transport_outcome_code", + self.code.len(), + RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, + )?; + if self.code != self.kind.as_str() { + return Err(crate::RadrootsTransportError::TransportOutcomeCodeMismatch); + } + if self.retry_class != self.kind.retry_class() { + return Err(crate::RadrootsTransportError::TransportOutcomeRetryClassMismatch); } if let Some(message) = &self.message { crate::limits::ensure_resource_limit( @@ -233,17 +276,18 @@ struct RadrootsTransportOutcomeWire { kind: RadrootsTransportOutcomeKind, status: RadrootsTransportDeliveryTargetStatus, #[serde(deserialize_with = "deserialize_outcome_code")] - code: Option<String>, + code: String, + retry_class: RadrootsTransportRetryClass, #[serde(deserialize_with = "deserialize_outcome_message")] message: Option<String>, } #[cfg(feature = "serde")] -fn deserialize_outcome_code<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> +fn deserialize_outcome_code<'de, D>(deserializer: D) -> Result<String, D::Error> where D: serde::Deserializer<'de>, { - crate::serde_bounds::deserialize_option_string( + crate::serde_bounds::deserialize_string( deserializer, "transport_outcome_code", RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, @@ -273,6 +317,7 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportOutcome { kind: wire.kind, status: wire.status, code: wire.code, + retry_class: wire.retry_class, message: wire.message, }; outcome.validate().map_err(serde::de::Error::custom)?; diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -103,6 +103,10 @@ const FORBIDDEN_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ const FORBIDDEN_CORE_STATUS_CONCEPTS: &[ForbiddenConcept] = &[ ForbiddenConcept { + pattern: "try_with_code", + reason: "transport outcome codes must derive from their authoritative outcome kind", + }, + ForbiddenConcept { pattern: "implementation_state", reason: "public transport status must use implementation", }, diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -18,7 +18,7 @@ use radroots_transport::{ RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransportPayload, RadrootsTransportRetryClass, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportSatisfactionPolicyKind, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, @@ -1347,6 +1347,12 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { &[], ), ( + RadrootsTransportOutcomeKind::ChallengeRequired, + "challenge_required", + RadrootsTransportDeliveryTargetStatus::FailedRetryable, + &[], + ), + ( RadrootsTransportOutcomeKind::Timeout, "timeout", RadrootsTransportDeliveryTargetStatus::FailedRetryable, @@ -1373,6 +1379,9 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { assert_eq!(kind.as_str(), label); assert_eq!(outcome.kind(), kind); assert_eq!(outcome.status(), status); + assert_eq!(outcome.code(), label); + assert_eq!(outcome.retry_class(), kind.retry_class()); + assert_eq!(outcome.is_retryable(), status.is_retryable_failure()); for class in classes { assert_eq!( kind.counts_as_satisfied(class), @@ -1395,6 +1404,99 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { } #[test] +fn outcome_coherence_checked_in_matrix_drives_core_wire_semantics() { + let vectors = + include_str!("../../../contracts/conformance/vectors/transport/outcome_matrix.v1.json"); + let document: Value = serde_json::from_str(vectors).expect("transport outcome matrix json"); + let entries = document + .get("vectors") + .and_then(Value::as_array) + .expect("transport outcome vectors"); + let extension_policy = entries + .iter() + .find(|entry| { + entry.get("kind").and_then(Value::as_str) + == Some("transport.outcome_matrix.extension_code_policy") + }) + .expect("extension code policy vector"); + assert_eq!( + extension_policy + .get("expected") + .and_then(|expected| expected.get("result")) + .and_then(Value::as_str), + Some("reject_before_transport_execution") + ); + let core_entries = entries + .iter() + .filter(|entry| { + entry.get("kind").and_then(Value::as_str) == Some("transport.outcome_matrix.core") + }) + .collect::<Vec<_>>(); + let expected_inventory = [ + RadrootsTransportOutcomeKind::Accepted, + RadrootsTransportOutcomeKind::DuplicateAccepted, + RadrootsTransportOutcomeKind::Delivered, + RadrootsTransportOutcomeKind::Forwarded, + RadrootsTransportOutcomeKind::StoredByGateway, + RadrootsTransportOutcomeKind::Seen, + RadrootsTransportOutcomeKind::DeferredUntilImplemented, + RadrootsTransportOutcomeKind::Rejected, + RadrootsTransportOutcomeKind::RouteUnavailable, + RadrootsTransportOutcomeKind::PayloadTooLarge, + RadrootsTransportOutcomeKind::PolicyDenied, + RadrootsTransportOutcomeKind::ChallengeRequired, + RadrootsTransportOutcomeKind::Timeout, + RadrootsTransportOutcomeKind::ConnectionFailed, + RadrootsTransportOutcomeKind::TransportUnavailable, + ]; + assert_eq!(core_entries.len(), expected_inventory.len()); + + for (entry, expected_kind) in core_entries.into_iter().zip(expected_inventory) { + let input = entry.get("input").expect("outcome matrix input"); + let expected = entry.get("expected").expect("outcome matrix expectation"); + let kind: RadrootsTransportOutcomeKind = + serde_json::from_value(input.get("outcome_kind").expect("outcome kind").clone()) + .expect("known outcome kind"); + let status: RadrootsTransportDeliveryTargetStatus = serde_json::from_value( + expected + .get("target_status") + .expect("target status") + .clone(), + ) + .expect("known target status"); + let retry_class: RadrootsTransportRetryClass = + serde_json::from_value(expected.get("retry_class").expect("retry class").clone()) + .expect("known retry class"); + let code = expected + .get("code") + .and_then(Value::as_str) + .expect("outcome code"); + let outcome = RadrootsTransportOutcome::new(kind); + + assert_eq!(kind, expected_kind); + assert_eq!(kind.as_str(), code); + assert_eq!(kind.target_status(), status); + assert_eq!(kind.retry_class(), retry_class); + assert_eq!(outcome.code(), code); + assert_eq!(outcome.status(), status); + assert_eq!(outcome.retry_class(), retry_class); + assert_eq!( + outcome.is_retryable(), + retry_class == RadrootsTransportRetryClass::Retryable + ); + outcome.validate().expect("matrix outcome is coherent"); + + let wire = serde_json::to_value(&outcome).expect("outcome wire"); + assert_eq!(wire.get("code").and_then(Value::as_str), Some(code)); + assert_eq!( + serde_json::from_value::<RadrootsTransportOutcome>(wire) + .expect("coherent outcome wire"), + outcome + ); + } +} + +#[test] fn required_target_satisfaction_uses_fingerprints_not_target_counts() { let required = RadrootsTransportTarget::nostr_relay("wss://one.example").expect("required target"); @@ -1903,33 +2005,47 @@ fn delivery_request_and_receipt_deserialization_revalidates_invariants() { let exact_outcome_wire = serde_json::json!({ "kind": "Accepted", "status": "Accepted", - "code": "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES), + "code": "accepted", + "retry_class": "None", "message": "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES), }); let exact_outcome_json = serde_json::to_string(&exact_outcome_wire).expect("exact outcome JSON"); serde_json::from_str::<RadrootsTransportOutcome>(&exact_outcome_json) .expect("decode exact outcome wire"); - for (field, value, expected_limit) in [ - ( - "code", - "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1), - "transport_outcome_code", - ), - ( - "message", - "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1), - "transport_outcome_message", - ), + let mut one_over_message = exact_outcome_wire.clone(); + one_over_message["message"] = + Value::String("m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1)); + let encoded = serde_json::to_string(&one_over_message).expect("one-over outcome JSON"); + assert!( + serde_json::from_str::<RadrootsTransportOutcome>(&encoded) + .expect_err("reject one-over outcome message") + .to_string() + .contains("transport_outcome_message") + ); + let mut one_over_code = exact_outcome_wire.clone(); + one_over_code["code"] = + Value::String("c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1)); + let encoded = serde_json::to_string(&one_over_code).expect("one-over outcome JSON"); + assert!( + serde_json::from_str::<RadrootsTransportOutcome>(&encoded) + .expect_err("reject one-over outcome code") + .to_string() + .contains("transport_outcome_code") + ); + for (field, value, expected_error) in [ + ("status", "FailedTerminal", "status"), + ("code", "rejected", "code"), + ("code", "vendor_retryable", "code"), + ("retry_class", "Terminal", "retry class"), ] { - let mut one_over = exact_outcome_wire.clone(); - one_over[field] = Value::String(value); - let encoded = serde_json::to_string(&one_over).expect("one-over outcome JSON"); + let mut incoherent = exact_outcome_wire.clone(); + incoherent[field] = Value::String(value.to_owned()); assert!( - serde_json::from_str::<RadrootsTransportOutcome>(&encoded) - .expect_err("reject one-over outcome wire") + serde_json::from_value::<RadrootsTransportOutcome>(incoherent) + .expect_err("reject incoherent outcome wire") .to_string() - .contains(expected_limit) + .contains(expected_error) ); } let mut unknown_outcome = exact_outcome_wire; @@ -2140,12 +2256,10 @@ fn payload_contract_covers_all_validation_boundaries() { fn status_contract_covers_builders_and_availability_defaults() { assert_eq!( RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) - .try_with_code("accepted") - .expect("bounded code") .try_with_message("accepted by transport") .expect("bounded message") .code(), - Some("accepted") + "accepted" ); assert!(!RadrootsTransportCapabilities::none().can_deliver()); @@ -2699,33 +2813,17 @@ fn transport_bounds_targets_enforce_exact_and_one_over_before_set_work() { #[test] fn transport_bounds_outcomes_and_receipts_enforce_exact_and_one_over() { - let exact_code = "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES); let exact_message = "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES); let exact_outcome = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) - .try_with_code(exact_code) - .expect("exact outcome code") .try_with_message(exact_message) .expect("exact outcome message"); - assert_eq!( - exact_outcome.code().map(str::len), - Some(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES) - ); + assert_eq!(exact_outcome.code(), "accepted"); assert_eq!( exact_outcome.message().map(str::len), Some(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES) ); assert_eq!( RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) - .try_with_code("c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1)) - .expect_err("one-over outcome code"), - RadrootsTransportError::ResourceLimitExceeded { - field: "transport_outcome_code", - max: RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, - actual: RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1, - } - ); - assert_eq!( - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) .try_with_message("m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1)) .expect_err("one-over outcome message"), RadrootsTransportError::ResourceLimitExceeded { @@ -3270,6 +3368,8 @@ fn every_transport_error_has_a_stable_display_message() { RadrootsTransportError::DeliveryTargetReceiptStatusMismatch, RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch, RadrootsTransportError::TransportOutcomeStatusMismatch, + RadrootsTransportError::TransportOutcomeCodeMismatch, + RadrootsTransportError::TransportOutcomeRetryClassMismatch, RadrootsTransportError::DeliveryReceiptRequestIdMismatch, RadrootsTransportError::DeliveryReceiptTargetSetMismatch, RadrootsTransportError::EmptyFetchRequestId, diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -785,36 +785,10 @@ fn relay_receipts_from_transport_receipts( fn relay_outcome_from_transport_outcome( outcome: &RadrootsTransportOutcome, ) -> Result<RadrootsRelayOutcome, RadrootsRelayTransportError> { - let kind = outcome - .code() - .and_then(relay_outcome_kind_from_code) - .unwrap_or_else(|| relay_outcome_kind_from_transport_outcome(outcome.kind())); + let kind = relay_outcome_kind_from_transport_outcome(outcome.kind()); RadrootsRelayOutcome::try_new(kind, outcome.message().map(str::to_owned)) } -fn relay_outcome_kind_from_code(code: &str) -> Option<crate::RadrootsRelayOutcomeKind> { - Some(match code { - "accepted" => crate::RadrootsRelayOutcomeKind::Accepted, - "duplicate_accepted" => crate::RadrootsRelayOutcomeKind::DuplicateAccepted, - "blocked" => crate::RadrootsRelayOutcomeKind::Blocked, - "rate_limited" => crate::RadrootsRelayOutcomeKind::RateLimited, - "invalid" => crate::RadrootsRelayOutcomeKind::Invalid, - "pow_required" => crate::RadrootsRelayOutcomeKind::PowRequired, - "restricted" => crate::RadrootsRelayOutcomeKind::Restricted, - "auth_required" => crate::RadrootsRelayOutcomeKind::AuthRequired, - "muted" => crate::RadrootsRelayOutcomeKind::Muted, - "unsupported" => crate::RadrootsRelayOutcomeKind::Unsupported, - "payment_required" => crate::RadrootsRelayOutcomeKind::PaymentRequired, - "error" => crate::RadrootsRelayOutcomeKind::Error, - "timeout" => crate::RadrootsRelayOutcomeKind::Timeout, - "connection_failed" => crate::RadrootsRelayOutcomeKind::ConnectionFailed, - "relay_url_rejected" => crate::RadrootsRelayOutcomeKind::RelayUrlRejected, - "skipped_already_accepted" => crate::RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, - "unknown" => crate::RadrootsRelayOutcomeKind::Unknown, - _ => return None, - }) -} - fn relay_outcome_kind_from_transport_outcome( kind: RadrootsTransportOutcomeKind, ) -> crate::RadrootsRelayOutcomeKind { @@ -828,6 +802,9 @@ fn relay_outcome_kind_from_transport_outcome( crate::RadrootsRelayOutcomeKind::RelayUrlRejected } RadrootsTransportOutcomeKind::PolicyDenied => crate::RadrootsRelayOutcomeKind::Restricted, + RadrootsTransportOutcomeKind::ChallengeRequired => { + crate::RadrootsRelayOutcomeKind::ChallengeRequired + } RadrootsTransportOutcomeKind::Timeout => crate::RadrootsRelayOutcomeKind::Timeout, RadrootsTransportOutcomeKind::ConnectionFailed => { crate::RadrootsRelayOutcomeKind::ConnectionFailed @@ -917,6 +894,8 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela | RadrootsTransportError::DeliveryTargetReceiptStatusMismatch | RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch | RadrootsTransportError::TransportOutcomeStatusMismatch + | RadrootsTransportError::TransportOutcomeCodeMismatch + | RadrootsTransportError::TransportOutcomeRetryClassMismatch | RadrootsTransportError::DeliveryReceiptRequestIdMismatch | RadrootsTransportError::DeliveryReceiptTargetSetMismatch | RadrootsTransportError::UnexpectedFetchTargetReceipt @@ -1171,11 +1150,10 @@ mod tests { PublishableRelay, PublishableRelays, RadrootsOutboxDeliveryTargetStatus, adapter_transport_failure_receipt, counts_as_accepted_for_plan, is_publishable_delivery_status, publishable_transport_targets, - relay_outcome_from_transport_outcome, relay_outcome_kind_from_code, - relay_outcome_kind_from_transport_outcome, relay_receipts_from_transport_receipts, - satisfaction_policy_for_remaining_count, target_receipts_from_relay_receipts, - target_receipts_from_transport_receipts, transport_error_to_relay_error, - transport_satisfaction_policy_for_publishable, + relay_outcome_from_transport_outcome, relay_outcome_kind_from_transport_outcome, + relay_receipts_from_transport_receipts, satisfaction_policy_for_remaining_count, + target_receipts_from_relay_receipts, target_receipts_from_transport_receipts, + transport_error_to_relay_error, transport_satisfaction_policy_for_publishable, }; use crate::{ RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishRelayReceipt, @@ -1426,6 +1404,10 @@ mod tests { RadrootsRelayOutcomeKind::Restricted, ), ( + RadrootsTransportOutcomeKind::ChallengeRequired, + RadrootsRelayOutcomeKind::ChallengeRequired, + ), + ( RadrootsTransportOutcomeKind::Timeout, RadrootsRelayOutcomeKind::Timeout, ), @@ -1452,65 +1434,6 @@ mod tests { assert_eq!(relay_outcome.message(), outcome.message()); } - let code_cases = [ - ("accepted", RadrootsRelayOutcomeKind::Accepted), - ( - "duplicate_accepted", - RadrootsRelayOutcomeKind::DuplicateAccepted, - ), - ("blocked", RadrootsRelayOutcomeKind::Blocked), - ("rate_limited", RadrootsRelayOutcomeKind::RateLimited), - ("invalid", RadrootsRelayOutcomeKind::Invalid), - ("pow_required", RadrootsRelayOutcomeKind::PowRequired), - ("restricted", RadrootsRelayOutcomeKind::Restricted), - ("auth_required", RadrootsRelayOutcomeKind::AuthRequired), - ("muted", RadrootsRelayOutcomeKind::Muted), - ("unsupported", RadrootsRelayOutcomeKind::Unsupported), - ( - "payment_required", - RadrootsRelayOutcomeKind::PaymentRequired, - ), - ("error", RadrootsRelayOutcomeKind::Error), - ("timeout", RadrootsRelayOutcomeKind::Timeout), - ( - "connection_failed", - RadrootsRelayOutcomeKind::ConnectionFailed, - ), - ( - "relay_url_rejected", - RadrootsRelayOutcomeKind::RelayUrlRejected, - ), - ( - "skipped_already_accepted", - RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, - ), - ("unknown", RadrootsRelayOutcomeKind::Unknown), - ]; - for (code, relay_kind) in code_cases { - assert_eq!(relay_outcome_kind_from_code(code), Some(relay_kind)); - assert_eq!( - relay_outcome_from_transport_outcome( - &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected) - .try_with_code(code) - .expect("bounded test outcome code") - ) - .expect("bounded relay outcome") - .kind(), - relay_kind - ); - } - assert_eq!(relay_outcome_kind_from_code("unrecognized"), None); - assert_eq!( - relay_outcome_from_transport_outcome( - &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) - .try_with_code("unrecognized") - .expect("bounded test outcome code") - ) - .expect("bounded relay outcome") - .kind(), - RadrootsRelayOutcomeKind::Accepted - ); - for kind in [ RadrootsRelayOutcomeKind::RateLimited, RadrootsRelayOutcomeKind::Error, diff --git a/crates/transport_nostr/src/outcome.rs b/crates/transport_nostr/src/outcome.rs @@ -17,6 +17,7 @@ pub enum RadrootsRelayOutcomeKind { PowRequired, Restricted, AuthRequired, + ChallengeRequired, Muted, Unsupported, PaymentRequired, @@ -39,6 +40,7 @@ impl RadrootsRelayOutcomeKind { Self::PowRequired => "pow_required", Self::Restricted => "restricted", Self::AuthRequired => "auth_required", + Self::ChallengeRequired => "challenge_required", Self::Muted => "muted", Self::Unsupported => "unsupported", Self::PaymentRequired => "payment_required", @@ -64,6 +66,7 @@ impl RadrootsRelayOutcomeKind { Self::RateLimited | Self::PowRequired | Self::AuthRequired + | Self::ChallengeRequired | Self::Error | Self::Timeout | Self::ConnectionFailed @@ -94,9 +97,10 @@ impl RadrootsRelayOutcomeKind { RadrootsTransportOutcomeKind::Rejected } Self::RelayUrlRejected => RadrootsTransportOutcomeKind::RouteUnavailable, - Self::PaymentRequired | Self::PowRequired | Self::AuthRequired => { - RadrootsTransportOutcomeKind::PolicyDenied + Self::PowRequired | Self::AuthRequired | Self::ChallengeRequired => { + RadrootsTransportOutcomeKind::ChallengeRequired } + Self::PaymentRequired => RadrootsTransportOutcomeKind::PolicyDenied, Self::RateLimited | Self::Error | Self::Unknown => { RadrootsTransportOutcomeKind::TransportUnavailable } @@ -234,8 +238,7 @@ impl RadrootsRelayOutcome { } pub fn to_transport_outcome(&self) -> Result<RadrootsTransportOutcome, RadrootsTransportError> { - let mut outcome = RadrootsTransportOutcome::new(self.kind.transport_outcome_kind()) - .try_with_code(self.kind.as_str())?; + let mut outcome = RadrootsTransportOutcome::new(self.kind.transport_outcome_kind()); if let Some(message) = &self.message { outcome = outcome.try_with_message(message.clone())?; } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1128,6 +1128,10 @@ fn outcome_prefix_classification_covers_required_kinds() { (RadrootsRelayOutcomeKind::PowRequired, "pow_required"), (RadrootsRelayOutcomeKind::Restricted, "restricted"), (RadrootsRelayOutcomeKind::AuthRequired, "auth_required"), + ( + RadrootsRelayOutcomeKind::ChallengeRequired, + "challenge_required", + ), (RadrootsRelayOutcomeKind::Muted, "muted"), (RadrootsRelayOutcomeKind::Unsupported, "unsupported"), ( @@ -1251,6 +1255,92 @@ fn outcome_prefix_classification_covers_required_kinds() { } #[test] +fn outcome_coherence_checked_in_matrix_drives_nostr_retry_mapping() { + let vectors = + include_str!("../../../contracts/conformance/vectors/transport/outcome_matrix.v1.json"); + let document: serde_json::Value = + serde_json::from_str(vectors).expect("transport outcome matrix json"); + let entries = document + .get("vectors") + .and_then(serde_json::Value::as_array) + .expect("transport outcome vectors"); + let relay_entries = entries + .iter() + .filter(|entry| { + entry.get("kind").and_then(serde_json::Value::as_str) + == Some("transport.outcome_matrix.nostr_relay") + }) + .collect::<Vec<_>>(); + let expected_inventory = [ + RadrootsRelayOutcomeKind::Accepted, + RadrootsRelayOutcomeKind::DuplicateAccepted, + RadrootsRelayOutcomeKind::Blocked, + RadrootsRelayOutcomeKind::RateLimited, + RadrootsRelayOutcomeKind::Invalid, + RadrootsRelayOutcomeKind::PowRequired, + RadrootsRelayOutcomeKind::Restricted, + RadrootsRelayOutcomeKind::AuthRequired, + RadrootsRelayOutcomeKind::ChallengeRequired, + RadrootsRelayOutcomeKind::Muted, + RadrootsRelayOutcomeKind::Unsupported, + RadrootsRelayOutcomeKind::PaymentRequired, + RadrootsRelayOutcomeKind::Error, + RadrootsRelayOutcomeKind::Timeout, + RadrootsRelayOutcomeKind::ConnectionFailed, + RadrootsRelayOutcomeKind::RelayUrlRejected, + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, + RadrootsRelayOutcomeKind::Unknown, + ]; + assert_eq!(relay_entries.len(), expected_inventory.len()); + + for (entry, expected_kind) in relay_entries.into_iter().zip(expected_inventory) { + let input = entry.get("input").expect("relay matrix input"); + let expected = entry.get("expected").expect("relay matrix expectation"); + let relay_kind: RadrootsRelayOutcomeKind = + serde_json::from_value(input.get("relay_kind").expect("relay kind").clone()) + .expect("known relay outcome kind"); + let transport_kind: RadrootsTransportOutcomeKind = serde_json::from_value( + expected + .get("transport_kind") + .expect("transport kind") + .clone(), + ) + .expect("known transport outcome kind"); + let code = expected + .get("code") + .and_then(serde_json::Value::as_str) + .expect("relay outcome code"); + let retryable = expected + .get("retryable") + .and_then(serde_json::Value::as_bool) + .expect("retryable flag"); + let terminal = expected + .get("terminal") + .and_then(serde_json::Value::as_bool) + .expect("terminal flag"); + let counts_toward_quorum = expected + .get("counts_toward_quorum") + .and_then(serde_json::Value::as_bool) + .expect("quorum flag"); + + assert_eq!(relay_kind, expected_kind); + assert_eq!(relay_kind.as_str(), code); + assert_eq!(relay_kind.transport_outcome_kind(), transport_kind); + assert_eq!(relay_kind.is_retryable(), retryable); + assert_eq!(relay_kind.is_terminal_failure(), terminal); + assert_eq!(relay_kind.counts_toward_quorum(), counts_toward_quorum); + + let relay_outcome = + RadrootsRelayOutcome::try_new(relay_kind, None).expect("matrix relay outcome"); + let transport_outcome = relay_outcome + .to_transport_outcome() + .expect("matrix transport outcome"); + assert_eq!(transport_outcome.kind(), transport_kind); + assert_eq!(transport_outcome.is_retryable(), retryable); + } +} + +#[test] fn relay_outcome_messages_are_bounded_and_strictly_decoded() { let exact_message = "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES); let outcome = RadrootsRelayOutcome::unknown(exact_message.clone()) @@ -4240,7 +4330,7 @@ async fn outbox_transport_facade_persists_every_delivery_status() { let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); let draft = generic_draft("transport outcome matrix"); - let relays = (0..14) + let relays = (0..15) .map(|index| format!("wss://relay-{index}.example.com")) .collect::<Vec<_>>(); let receipt = outbox @@ -4270,6 +4360,7 @@ async fn outbox_transport_facade_persists_every_delivery_status() { RadrootsTransportOutcomeKind::RouteUnavailable, RadrootsTransportOutcomeKind::PayloadTooLarge, RadrootsTransportOutcomeKind::PolicyDenied, + RadrootsTransportOutcomeKind::ChallengeRequired, RadrootsTransportOutcomeKind::Timeout, RadrootsTransportOutcomeKind::ConnectionFailed, RadrootsTransportOutcomeKind::TransportUnavailable, @@ -4291,13 +4382,13 @@ async fn outbox_transport_facade_persists_every_delivery_status() { .expect("transport publish"); assert_eq!(published.event_id(), signed.id_str()); - assert_eq!(published.attempted_count(), 14); + assert_eq!(published.attempted_count(), 15); assert_eq!(published.accepted_count(), 6); - assert_eq!(published.retryable_count(), 3); + assert_eq!(published.retryable_count(), 4); assert_eq!(published.terminal_count(), 5); assert!(!published.quorum_met()); - assert_eq!(published.target_receipts().len(), 14); - assert_eq!(published.relay_receipts().len(), 14); + assert_eq!(published.target_receipts().len(), 15); + assert_eq!(published.relay_receipts().len(), 15); let targets = outbox .delivery_targets(receipt.outbox_event_id) .await @@ -4317,6 +4408,7 @@ async fn outbox_transport_facade_persists_every_delivery_status() { RadrootsOutboxDeliveryTargetStatus::FailedRetryable, RadrootsOutboxDeliveryTargetStatus::FailedRetryable, RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, ]; assert_eq!(targets.len(), expected_statuses.len()); for (target, expected_status) in targets.iter().zip(expected_statuses) { @@ -4340,7 +4432,8 @@ fn transport_outcome_wire_rejects_pending_accepted_status() { let error = serde_json::from_value::<RadrootsTransportOutcome>(serde_json::json!({ "kind": "Accepted", "status": "Pending", - "code": null, + "code": "accepted", + "retry_class": "None", "message": null, })) .expect_err("pending accepted outcome rejected before transport execution"); diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -24,8 +24,6 @@ use radroots_transport::{ const DEFAULT_PROFILE_ID: &str = "transport.reticulum.default"; const RETICULUM_AGENT_ENDPOINT_PREFIX: &str = "reticulum-agent:"; -const UNAVAILABLE_CODE: &str = "transport_unavailable"; -const DEFERRED_CODE: &str = "deferred_until_implemented"; const DEFERRED_MESSAGE: &str = "Reticulum delivery is deferred until implementation"; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -753,18 +751,13 @@ fn ensure_reticulum_targets( fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOutcome { let outcome = match behavior { RadrootsReticulumBehavior::RejectDeliveryAttempts => { - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::TransportUnavailable) } RadrootsReticulumBehavior::DeferDeliveryPlans => { RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) } }; outcome - .try_with_code(match behavior { - RadrootsReticulumBehavior::RejectDeliveryAttempts => UNAVAILABLE_CODE, - RadrootsReticulumBehavior::DeferDeliveryPlans => DEFERRED_CODE, - }) - .expect("static Reticulum outcome code is bounded") .try_with_message(match behavior { RadrootsReticulumBehavior::RejectDeliveryAttempts => { RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE @@ -1045,7 +1038,7 @@ mod tests { RadrootsReticulumBehavior::DeferDeliveryPlans, ] { let outcome = reticulum_outcome(behavior); - assert!(outcome.code().is_some()); + assert!(!outcome.code().is_empty()); assert!(outcome.message().is_some()); } } diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs @@ -309,7 +309,7 @@ fn direct_reticulum_delivery_accepts_any_typed_scope_as_inert_metadata() { ); assert_eq!( receipt.target_receipts()[0].status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); assert_eq!( receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), @@ -380,7 +380,7 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() { .expect("delivery receipt"); assert_eq!( delivery.target_receipts()[0].status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); let fetch = futures::executor::block_on(RadrootsTransport::fetch( @@ -391,7 +391,7 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() { assert_eq!(fetch.fetched_count(), 0); assert_eq!( fetch.target_receipts()[0].status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); } @@ -413,12 +413,9 @@ fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing ); assert_eq!( target_receipt.status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented - ); - assert_eq!( - target_receipt.outcome().code(), - Some("transport_unavailable") + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); + assert_eq!(target_receipt.outcome().code(), "transport_unavailable"); assert_eq!( target_receipt.outcome().message(), Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) @@ -465,7 +462,7 @@ fn deferred_delivery_plan_mode_never_counts_as_satisfied() { ); assert_eq!( receipt.target_receipts()[0].outcome().code(), - Some("deferred_until_implemented") + "deferred_until_implemented" ); assert!( !RadrootsTransportSatisfactionPolicy::any_accepted() @@ -497,7 +494,7 @@ fn reticulum_target_constructor_always_supplies_typed_scope() { } #[test] -fn fetch_reports_deferred_until_implemented_without_observed_events() { +fn fetch_reports_authoritative_unavailable_or_deferred_outcomes() { let transport = RadrootsReticulumTransport::default(); assert_eq!( transport.profile().profile_id(), @@ -522,7 +519,7 @@ fn fetch_reports_deferred_until_implemented_without_observed_events() { assert_eq!(receipt.agent_endpoint(), None); assert_eq!( receipt.outcome().status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); assert_eq!( RadrootsReticulumFetchRequest::new("fetch-0", 0).expect_err("zero limit"), @@ -590,7 +587,7 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() { .expect("delivery receipt"); assert_eq!( receipt.target_receipts()[0].status(), - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented + RadrootsTransportDeliveryTargetStatus::FailedRetryable ); let fetch = transport .fetch(RadrootsReticulumFetchRequest::new("fetch-agent", 1).expect("fetch"))