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