lib

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

v5.rs (61061B)


      1 //! Daemon transport-publish wire contract generation 5.
      2 //!
      3 //! The types in this module are passive serialized DTOs. Transport-native
      4 //! targets, relay clients, delivery execution, and conversions belong to their
      5 //! owning transport and host packages.
      6 
      7 use alloc::{borrow::ToOwned, collections::BTreeSet, string::String, vec, vec::Vec};
      8 use core::fmt;
      9 
     10 use crate::schema::{Descriptor as SchemaDescriptor, ModuleVersion, Registry};
     11 
     12 /// Stable daemon API/schema identity.
     13 pub const API_VERSION: &str = "radrootsd.transport_publish.v5";
     14 /// Stable daemon identity.
     15 pub const DAEMON_NAME: &str = "radrootsd";
     16 /// Capabilities RPC method.
     17 pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities";
     18 /// Event publication RPC method.
     19 pub const METHOD_EVENT: &str = "transport.publish.event";
     20 /// Job lookup RPC method.
     21 pub const METHOD_JOB_GET: &str = "transport.publish.job.get";
     22 /// Job listing RPC method.
     23 pub const METHOD_JOB_LIST: &str = "transport.publish.job.list";
     24 /// Canonical V5 Reticulum endpoint identity.
     25 pub const RETICULUM_ENDPOINT_URI: &str = "reticulum:local";
     26 /// Stable V5 Reticulum unavailability message.
     27 pub const RETICULUM_UNAVAILABLE_MESSAGE: &str = concat!(
     28     "Reticulum transport is configured, ",
     29     "but this build does not implement Reticulum delivery."
     30 );
     31 
     32 #[derive(Clone, Debug, PartialEq, Eq)]
     33 pub enum Error {
     34     InvalidHexField {
     35         field: &'static str,
     36         expected_len: usize,
     37     },
     38     EmptyRawEventJson,
     39     EmptyTag {
     40         index: usize,
     41     },
     42     EmptyIdempotencyKey,
     43     EmptyTransportKind {
     44         index: usize,
     45     },
     46     InvalidTransportKind {
     47         index: usize,
     48     },
     49     EmptyEndpointUri {
     50         index: usize,
     51     },
     52     InvalidEndpointUri {
     53         index: usize,
     54     },
     55     EmptyTargetScope {
     56         index: usize,
     57     },
     58     InvalidTargetScope {
     59         index: usize,
     60     },
     61     EmptyTargetLabel {
     62         index: usize,
     63     },
     64     InvalidTargetLabel {
     65         index: usize,
     66     },
     67     InvalidReticulumBehavior {
     68         index: usize,
     69     },
     70     InvalidTimeoutMs,
     71     InvalidReticulumEndpoint {
     72         index: usize,
     73     },
     74     DuplicateTarget {
     75         index: usize,
     76     },
     77     TargetLimitExceeded {
     78         max: usize,
     79         actual: usize,
     80     },
     81     EmptyTargetSet,
     82     InvalidQuorum,
     83     EmptyRequiredTargetSet,
     84     DuplicateRequiredTargetFingerprint {
     85         index: usize,
     86     },
     87     RequiredTargetNotInTargetSet {
     88         index: usize,
     89     },
     90     EmptyPrincipalId,
     91     EmptyJobId,
     92     InvalidJobTargetCount {
     93         expected: usize,
     94         actual: usize,
     95     },
     96     InvalidJobAcknowledgedCount {
     97         expected: usize,
     98         actual: usize,
     99     },
    100     InvalidJobRetryableCount {
    101         expected: usize,
    102         actual: usize,
    103     },
    104     InvalidJobTerminalCount {
    105         expected: usize,
    106         actual: usize,
    107     },
    108     InvalidJobTerminalState,
    109     InvalidJobDeliverySatisfiedState,
    110     InvalidJobCompletedAt,
    111     InvalidJobStatusState,
    112     InvalidExplicitTargetOutcome {
    113         index: usize,
    114     },
    115     InvalidTargetOutcomeKind {
    116         index: usize,
    117     },
    118     InvalidTargetSource {
    119         index: usize,
    120     },
    121     InvalidReticulumOutcome {
    122         index: usize,
    123     },
    124 }
    125 
    126 impl fmt::Display for Error {
    127     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    128         match self {
    129             Self::InvalidHexField {
    130                 field,
    131                 expected_len,
    132             } => write!(f, "{field} must be {expected_len} lowercase hex characters"),
    133             Self::EmptyRawEventJson => f.write_str("raw_event_json must not be empty"),
    134             Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"),
    135             Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"),
    136             Self::EmptyTransportKind { index } => {
    137                 write!(f, "transport target {index} kind must not be empty")
    138             }
    139             Self::InvalidTransportKind { index } => {
    140                 write!(
    141                     f,
    142                     "transport target {index} kind must be canonical lowercase"
    143                 )
    144             }
    145             Self::EmptyEndpointUri { index } => {
    146                 write!(f, "transport target {index} endpoint_uri must not be empty")
    147             }
    148             Self::InvalidEndpointUri { index } => {
    149                 write!(f, "transport target {index} endpoint_uri is invalid")
    150             }
    151             Self::EmptyTargetScope { index } => {
    152                 write!(f, "transport target {index} target_scope must not be empty")
    153             }
    154             Self::InvalidTargetScope { index } => {
    155                 write!(f, "transport target {index} target_scope must be canonical")
    156             }
    157             Self::EmptyTargetLabel { index } => {
    158                 write!(f, "transport target {index} target_label must not be empty")
    159             }
    160             Self::InvalidTargetLabel { index } => {
    161                 write!(f, "transport target {index} target_label is invalid")
    162             }
    163             Self::InvalidReticulumBehavior { index } => write!(
    164                 f,
    165                 "transport target {index} reticulum_behavior is only valid for Reticulum targets"
    166             ),
    167             Self::InvalidTimeoutMs => f.write_str("timeout_ms must be greater than zero"),
    168             Self::InvalidReticulumEndpoint { index } => write!(
    169                 f,
    170                 "transport target {index} Reticulum endpoint must be {RETICULUM_ENDPOINT_URI}"
    171             ),
    172             Self::DuplicateTarget { index } => {
    173                 write!(f, "transport target {index} duplicates an earlier target")
    174             }
    175             Self::TargetLimitExceeded { max, actual } => {
    176                 write!(f, "transport target count {actual} exceeds limit {max}")
    177             }
    178             Self::EmptyTargetSet => f.write_str("transport publish target set must not be empty"),
    179             Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"),
    180             Self::EmptyRequiredTargetSet => {
    181                 f.write_str("delivery required target set must not be empty")
    182             }
    183             Self::DuplicateRequiredTargetFingerprint { index } => {
    184                 write!(
    185                     f,
    186                     "delivery required target {index} duplicates an earlier fingerprint"
    187                 )
    188             }
    189             Self::RequiredTargetNotInTargetSet { index } => {
    190                 write!(
    191                     f,
    192                     "delivery required target {index} is not in the target set"
    193                 )
    194             }
    195             Self::EmptyPrincipalId => f.write_str("principal id must not be empty"),
    196             Self::EmptyJobId => f.write_str("job id must not be empty"),
    197             Self::InvalidJobTargetCount { expected, actual } => write!(
    198                 f,
    199                 "job target_count {actual} does not match {expected} target outcomes"
    200             ),
    201             Self::InvalidJobAcknowledgedCount { expected, actual } => write!(
    202                 f,
    203                 "job acknowledged_count {actual} does not match {expected} target outcomes"
    204             ),
    205             Self::InvalidJobRetryableCount { expected, actual } => write!(
    206                 f,
    207                 "job retryable_count {actual} does not match {expected} target outcomes"
    208             ),
    209             Self::InvalidJobTerminalCount { expected, actual } => write!(
    210                 f,
    211                 "job terminal_count {actual} does not match {expected} target outcomes"
    212             ),
    213             Self::InvalidJobTerminalState => f.write_str("job terminal flag does not match status"),
    214             Self::InvalidJobDeliverySatisfiedState => {
    215                 f.write_str("job delivery_satisfied flag does not match status")
    216             }
    217             Self::InvalidJobCompletedAt => {
    218                 f.write_str("job completed_at_ms does not match status or request time")
    219             }
    220             Self::InvalidJobStatusState => f.write_str("job status does not match target outcomes"),
    221             Self::InvalidExplicitTargetOutcome { index } => write!(
    222                 f,
    223                 "transport target outcome {index} does not match explicit target policy"
    224             ),
    225             Self::InvalidTargetOutcomeKind { index } => {
    226                 write!(
    227                     f,
    228                     "transport target outcome {index} kind is not valid for its transport"
    229                 )
    230             }
    231             Self::InvalidTargetSource { index } => {
    232                 write!(
    233                     f,
    234                     "transport target outcome {index} source does not match transport kind"
    235                 )
    236             }
    237             Self::InvalidReticulumOutcome { index } => write!(
    238                 f,
    239                 "transport target outcome {index} Reticulum must be unavailable or deferred"
    240             ),
    241         }
    242     }
    243 }
    244 
    245 #[cfg(feature = "std")]
    246 impl std::error::Error for Error {}
    247 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    248 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    249 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
    250 pub enum ReticulumBehavior {
    251     #[default]
    252     RejectDeliveryAttempts,
    253     DeferDeliveryPlans,
    254 }
    255 
    256 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    257 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    258 #[derive(Clone, Debug, PartialEq, Eq)]
    259 pub struct Target {
    260     pub transport_kind: String,
    261     pub endpoint_uri: String,
    262     #[cfg_attr(
    263         feature = "serde",
    264         serde(default, skip_serializing_if = "Option::is_none")
    265     )]
    266     pub target_scope: Option<String>,
    267     #[cfg_attr(
    268         feature = "serde",
    269         serde(default, skip_serializing_if = "Option::is_none")
    270     )]
    271     pub target_label: Option<String>,
    272     #[cfg_attr(
    273         feature = "serde",
    274         serde(default, skip_serializing_if = "Option::is_none")
    275     )]
    276     pub reticulum_behavior: Option<ReticulumBehavior>,
    277 }
    278 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    279 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    280 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    281 pub enum NostrTargetSourcePolicy {
    282     ExplicitOnly,
    283     RequestThenAuthorWriteThenDaemonDefault,
    284     AuthorWriteThenDaemonDefault,
    285     DaemonDefaultOnly,
    286 }
    287 
    288 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    289 #[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
    290 #[derive(Clone, Debug, PartialEq, Eq)]
    291 pub enum TargetPolicy {
    292     ExplicitTargets {
    293         targets: Vec<Target>,
    294     },
    295     Nostr {
    296         source_policy: NostrTargetSourcePolicy,
    297         #[cfg_attr(feature = "serde", serde(default))]
    298         relay_urls: Vec<String>,
    299     },
    300 }
    301 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    302 #[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))]
    303 #[derive(Clone, Debug, PartialEq, Eq)]
    304 pub enum DeliveryPolicy {
    305     Any,
    306     All,
    307     Quorum { quorum: usize },
    308     RequiredTargets { targets: Vec<TargetFingerprint> },
    309 }
    310 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    311 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    312 #[derive(Clone, Debug, PartialEq, Eq)]
    313 pub struct EventRequest {
    314     pub raw_event_json: String,
    315     pub target_policy: TargetPolicy,
    316     pub delivery_policy: DeliveryPolicy,
    317     #[cfg_attr(
    318         feature = "serde",
    319         serde(default, skip_serializing_if = "Option::is_none")
    320     )]
    321     pub idempotency_key: Option<String>,
    322     #[cfg_attr(
    323         feature = "serde",
    324         serde(default, skip_serializing_if = "Option::is_none")
    325     )]
    326     pub timeout_ms: Option<u64>,
    327 }
    328 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    329 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    330 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    331 pub enum JobStatus {
    332     Accepted,
    333     Publishing,
    334     DeliverySatisfied,
    335     DeliveryUnsatisfiedRetryable,
    336     DeliveryUnsatisfiedTerminal,
    337     DeliveryDeferred,
    338     DeliveryDeferredUntilImplemented,
    339     Rejected,
    340 }
    341 
    342 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    343 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    344 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    345 pub enum OutcomeKind {
    346     Accepted,
    347     DuplicateAccepted,
    348     Blocked,
    349     RateLimited,
    350     Invalid,
    351     PowRequired,
    352     Restricted,
    353     AuthRequired,
    354     Muted,
    355     Unsupported,
    356     PaymentRequired,
    357     Error,
    358     Timeout,
    359     ConnectionFailed,
    360     TargetRejected,
    361     SkippedAlreadyAccepted,
    362     DeferredUntilImplemented,
    363     Unknown,
    364 }
    365 
    366 impl OutcomeKind {
    367     pub fn counts_toward_accepted_delivery(self) -> bool {
    368         matches!(
    369             self,
    370             Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted
    371         )
    372     }
    373 
    374     pub fn is_retryable(self) -> bool {
    375         matches!(
    376             self,
    377             Self::RateLimited
    378                 | Self::PowRequired
    379                 | Self::AuthRequired
    380                 | Self::Error
    381                 | Self::Timeout
    382                 | Self::ConnectionFailed
    383                 | Self::Unknown
    384         )
    385     }
    386 
    387     pub fn is_terminal_failure(self) -> bool {
    388         matches!(
    389             self,
    390             Self::Blocked
    391                 | Self::Invalid
    392                 | Self::Restricted
    393                 | Self::Muted
    394                 | Self::Unsupported
    395                 | Self::PaymentRequired
    396                 | Self::TargetRejected
    397         )
    398     }
    399 
    400     pub fn is_deferred_until_implemented(self) -> bool {
    401         matches!(self, Self::DeferredUntilImplemented)
    402     }
    403 }
    404 
    405 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    406 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    407 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    408 pub enum TargetSource {
    409     Request,
    410     NostrAuthorWrite,
    411     DaemonDefault,
    412     Reticulum,
    413 }
    414 
    415 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    416 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    417 #[derive(Clone, Debug, PartialEq, Eq)]
    418 pub struct TargetOutcome {
    419     pub transport_kind: String,
    420     pub endpoint_uri: String,
    421     #[cfg_attr(
    422         feature = "serde",
    423         serde(default, skip_serializing_if = "Option::is_none")
    424     )]
    425     pub target_scope: Option<String>,
    426     #[cfg_attr(
    427         feature = "serde",
    428         serde(default, skip_serializing_if = "Option::is_none")
    429     )]
    430     pub target_label: Option<String>,
    431     pub source: TargetSource,
    432     pub attempted: bool,
    433     pub outcome_kind: OutcomeKind,
    434     #[cfg_attr(
    435         feature = "serde",
    436         serde(default, skip_serializing_if = "Option::is_none")
    437     )]
    438     pub message: Option<String>,
    439     #[cfg_attr(
    440         feature = "serde",
    441         serde(default, skip_serializing_if = "Option::is_none")
    442     )]
    443     pub latency_ms: Option<u64>,
    444 }
    445 
    446 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    447 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    448 #[derive(Clone, Debug, PartialEq, Eq)]
    449 pub struct Job {
    450     pub job_id: String,
    451     pub status: JobStatus,
    452     pub terminal: bool,
    453     pub delivery_satisfied: bool,
    454     pub event_id: String,
    455     pub pubkey: String,
    456     pub event_kind: u32,
    457     pub target_policy: TargetPolicy,
    458     pub delivery_policy: DeliveryPolicy,
    459     pub target_count: usize,
    460     pub acknowledged_count: usize,
    461     pub retryable_count: usize,
    462     pub terminal_count: usize,
    463     pub requested_at_ms: i64,
    464     #[cfg_attr(
    465         feature = "serde",
    466         serde(default, skip_serializing_if = "Option::is_none")
    467     )]
    468     pub completed_at_ms: Option<i64>,
    469     #[cfg_attr(
    470         feature = "serde",
    471         serde(default, skip_serializing_if = "Option::is_none")
    472     )]
    473     pub last_error: Option<String>,
    474     #[cfg_attr(feature = "serde", serde(default))]
    475     pub targets: Vec<TargetOutcome>,
    476 }
    477 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    478 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    479 #[derive(Clone, Debug, PartialEq, Eq)]
    480 pub struct EventResponse {
    481     pub deduplicated: bool,
    482     pub job: Job,
    483 }
    484 
    485 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    486 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    487 #[derive(Clone, Debug, PartialEq, Eq)]
    488 pub struct Capabilities {
    489     pub daemon: String,
    490     pub api_version: String,
    491     pub transports: Vec<String>,
    492     pub methods: Vec<String>,
    493     pub auth: AuthCapabilities,
    494     pub publish: SurfaceCapabilities,
    495 }
    496 
    497 impl Capabilities {
    498     pub fn v5(max_event_bytes: usize, max_targets_per_request: usize) -> Self {
    499         Self {
    500             daemon: DAEMON_NAME.to_owned(),
    501             api_version: API_VERSION.to_owned(),
    502             transports: vec!["jsonrpc_http".to_owned()],
    503             methods: vec![
    504                 METHOD_CAPABILITIES.to_owned(),
    505                 METHOD_EVENT.to_owned(),
    506                 METHOD_JOB_GET.to_owned(),
    507                 METHOD_JOB_LIST.to_owned(),
    508             ],
    509             auth: AuthCapabilities {
    510                 mode: "scoped_bearer_token".to_owned(),
    511             },
    512             publish: SurfaceCapabilities {
    513                 raw_event_json_ingress: true,
    514                 server_side_user_signing: false,
    515                 max_event_bytes,
    516                 max_targets_per_request,
    517                 delivery_policies: vec![
    518                     DeliveryPolicyName::Any,
    519                     DeliveryPolicyName::Quorum,
    520                     DeliveryPolicyName::All,
    521                     DeliveryPolicyName::RequiredTargets,
    522                 ],
    523                 target_policy_modes: vec![
    524                     TargetPolicyName::ExplicitTargets,
    525                     TargetPolicyName::Nostr,
    526                 ],
    527                 transports: vec![
    528                     TransportCapability {
    529                         transport: "nostr".to_owned(),
    530                         configured: true,
    531                         implementation: Implementation::Real,
    532                         maturity: CapabilityMaturity::Stable,
    533                         availability: CapabilityAvailability::Available,
    534                         usable_for_delivery: true,
    535                         capabilities: OperationCapabilities {
    536                             deliver: true,
    537                             fetch: false,
    538                             discovery: false,
    539                             gateway_forwarding: false,
    540                             receipt_observation: false,
    541                         },
    542                         reticulum_behavior: None,
    543                         message: "Nostr relay publish is available".to_owned(),
    544                     },
    545                     TransportCapability {
    546                         transport: "reticulum".to_owned(),
    547                         configured: true,
    548                         implementation: Implementation::Real,
    549                         maturity: CapabilityMaturity::Preview,
    550                         availability: CapabilityAvailability::Unavailable,
    551                         usable_for_delivery: false,
    552                         capabilities: OperationCapabilities {
    553                             deliver: false,
    554                             fetch: false,
    555                             discovery: false,
    556                             gateway_forwarding: false,
    557                             receipt_observation: false,
    558                         },
    559                         reticulum_behavior: Some(ReticulumBehavior::RejectDeliveryAttempts),
    560                         message: RETICULUM_UNAVAILABLE_MESSAGE.to_owned(),
    561                     },
    562                 ],
    563             },
    564         }
    565     }
    566 }
    567 
    568 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    569 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    570 #[derive(Clone, Debug, PartialEq, Eq)]
    571 pub struct AuthCapabilities {
    572     pub mode: String,
    573 }
    574 
    575 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    576 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    577 #[derive(Clone, Debug, PartialEq, Eq)]
    578 pub struct SurfaceCapabilities {
    579     pub raw_event_json_ingress: bool,
    580     pub server_side_user_signing: bool,
    581     pub max_event_bytes: usize,
    582     pub max_targets_per_request: usize,
    583     pub delivery_policies: Vec<DeliveryPolicyName>,
    584     pub target_policy_modes: Vec<TargetPolicyName>,
    585     pub transports: Vec<TransportCapability>,
    586 }
    587 
    588 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    589 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    590 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    591 pub enum Implementation {
    592     Real,
    593     Mock,
    594 }
    595 
    596 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    597 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    598 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    599 pub enum CapabilityMaturity {
    600     Preview,
    601     Stable,
    602 }
    603 
    604 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    605 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    606 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    607 pub enum CapabilityAvailability {
    608     Available,
    609     Degraded,
    610     Unavailable,
    611 }
    612 
    613 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    614 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    615 #[derive(Clone, Debug, PartialEq, Eq)]
    616 pub struct TransportCapability {
    617     pub transport: String,
    618     pub configured: bool,
    619     pub implementation: Implementation,
    620     pub maturity: CapabilityMaturity,
    621     pub availability: CapabilityAvailability,
    622     pub usable_for_delivery: bool,
    623     pub capabilities: OperationCapabilities,
    624     #[cfg_attr(
    625         feature = "serde",
    626         serde(default, skip_serializing_if = "Option::is_none")
    627     )]
    628     pub reticulum_behavior: Option<ReticulumBehavior>,
    629     pub message: String,
    630 }
    631 
    632 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    633 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    634 #[derive(Clone, Debug, PartialEq, Eq)]
    635 pub struct OperationCapabilities {
    636     pub deliver: bool,
    637     pub fetch: bool,
    638     pub discovery: bool,
    639     pub gateway_forwarding: bool,
    640     pub receipt_observation: bool,
    641 }
    642 
    643 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    644 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    645 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    646 pub enum DeliveryPolicyName {
    647     Any,
    648     Quorum,
    649     All,
    650     RequiredTargets,
    651 }
    652 
    653 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    654 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    655 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    656 pub enum TargetPolicyName {
    657     ExplicitTargets,
    658     Nostr,
    659 }
    660 
    661 /// Canonical serialized target fingerprint.
    662 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    663 pub struct TargetFingerprint(String);
    664 
    665 impl TargetFingerprint {
    666     /// Parses the exact lowercase 64-hex wire representation.
    667     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    668         let value = value.into();
    669         validate_lower_hex("target_fingerprint", value.as_str(), 64)?;
    670         Ok(Self(value))
    671     }
    672 
    673     /// Returns the canonical wire representation.
    674     pub fn as_str(&self) -> &str {
    675         self.0.as_str()
    676     }
    677 }
    678 
    679 #[cfg(feature = "serde")]
    680 impl serde::Serialize for TargetFingerprint {
    681     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    682     where
    683         S: serde::Serializer,
    684     {
    685         serializer.serialize_str(self.as_str())
    686     }
    687 }
    688 
    689 #[cfg(feature = "serde")]
    690 impl<'de> serde::Deserialize<'de> for TargetFingerprint {
    691     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    692     where
    693         D: serde::Deserializer<'de>,
    694     {
    695         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    696         Self::parse(value).map_err(serde::de::Error::custom)
    697     }
    698 }
    699 
    700 impl Target {
    701     /// Creates a passive Nostr target DTO.
    702     pub fn nostr(endpoint_uri: impl Into<String>) -> Self {
    703         Self {
    704             transport_kind: "nostr".to_owned(),
    705             endpoint_uri: endpoint_uri.into(),
    706             target_scope: None,
    707             target_label: None,
    708             reticulum_behavior: None,
    709         }
    710     }
    711 
    712     /// Creates the canonical passive Reticulum target DTO.
    713     pub fn reticulum(behavior: ReticulumBehavior) -> Self {
    714         Self {
    715             transport_kind: "reticulum".to_owned(),
    716             endpoint_uri: RETICULUM_ENDPOINT_URI.to_owned(),
    717             target_scope: None,
    718             target_label: None,
    719             reticulum_behavior: Some(behavior),
    720         }
    721     }
    722 
    723     /// Adds a serialized mesh scope.
    724     pub fn with_scope(mut self, target_scope: impl Into<String>) -> Self {
    725         self.target_scope = Some(target_scope.into());
    726         self
    727     }
    728 
    729     /// Adds a serialized display label.
    730     pub fn with_label(mut self, target_label: impl Into<String>) -> Self {
    731         self.target_label = Some(target_label.into());
    732         self
    733     }
    734 
    735     fn validate_structure(&self, index: usize) -> Result<(), Error> {
    736         match self.transport_kind.as_str() {
    737             "" => return Err(Error::EmptyTransportKind { index }),
    738             "local" | "nostr" | "reticulum" => {}
    739             _ if self.transport_kind.trim().is_empty() => {
    740                 return Err(Error::EmptyTransportKind { index });
    741             }
    742             _ => return Err(Error::InvalidTransportKind { index }),
    743         }
    744         validate_endpoint(
    745             self.transport_kind.as_str(),
    746             self.endpoint_uri.as_str(),
    747             index,
    748         )?;
    749         validate_target_metadata(
    750             self.target_scope.as_deref(),
    751             self.target_label.as_deref(),
    752             index,
    753         )?;
    754         if self.transport_kind != "reticulum" && self.reticulum_behavior.is_some() {
    755             return Err(Error::InvalidReticulumBehavior { index });
    756         }
    757         if self.transport_kind == "reticulum" && self.endpoint_uri != RETICULUM_ENDPOINT_URI {
    758             return Err(Error::InvalidReticulumEndpoint { index });
    759         }
    760         Ok(())
    761     }
    762 
    763     fn same_wire_identity(&self, outcome: &TargetOutcome) -> bool {
    764         self.transport_kind == outcome.transport_kind
    765             && self.endpoint_uri == outcome.endpoint_uri
    766             && self.target_scope == outcome.target_scope
    767     }
    768 }
    769 
    770 impl TargetPolicy {
    771     /// Creates an explicit-target policy.
    772     pub fn explicit_targets(targets: Vec<Target>) -> Self {
    773         Self::ExplicitTargets { targets }
    774     }
    775 
    776     /// Creates a Nostr relay-source policy.
    777     pub fn nostr(source_policy: NostrTargetSourcePolicy, relay_urls: Vec<String>) -> Self {
    778         Self::Nostr {
    779             source_policy,
    780             relay_urls,
    781         }
    782     }
    783 
    784     /// Returns the number of request-declared targets.
    785     pub fn request_target_count(&self) -> usize {
    786         match self {
    787             Self::ExplicitTargets { targets } => targets.len(),
    788             Self::Nostr { relay_urls, .. } => relay_urls.len(),
    789         }
    790     }
    791 
    792     fn validate_structure(&self, max_targets: usize) -> Result<(), Error> {
    793         match self {
    794             Self::ExplicitTargets { targets } => {
    795                 validate_target_limit(targets.len(), max_targets)?;
    796                 if targets.is_empty() {
    797                     return Err(Error::EmptyTargetSet);
    798                 }
    799                 for (index, target) in targets.iter().enumerate() {
    800                     target.validate_structure(index)?;
    801                     if targets[..index].iter().any(|prior| {
    802                         prior.transport_kind == target.transport_kind
    803                             && prior.endpoint_uri == target.endpoint_uri
    804                             && prior.target_scope == target.target_scope
    805                     }) {
    806                         return Err(Error::DuplicateTarget { index });
    807                     }
    808                 }
    809             }
    810             Self::Nostr { relay_urls, .. } => {
    811                 validate_target_limit(relay_urls.len(), max_targets)?;
    812                 for (index, endpoint) in relay_urls.iter().enumerate() {
    813                     validate_endpoint("nostr", endpoint, index)?;
    814                     if relay_urls[..index].contains(endpoint) {
    815                         return Err(Error::DuplicateTarget { index });
    816                     }
    817                 }
    818             }
    819         }
    820         Ok(())
    821     }
    822 }
    823 
    824 impl DeliveryPolicy {
    825     /// Creates a required-target policy after structural validation.
    826     pub fn required_targets(targets: Vec<TargetFingerprint>) -> Result<Self, Error> {
    827         validate_required_target_fingerprints(targets.as_slice())?;
    828         Ok(Self::RequiredTargets { targets })
    829     }
    830 
    831     /// Validates quorum and required-target structure.
    832     pub fn validate(&self) -> Result<(), Error> {
    833         match self {
    834             Self::Quorum { quorum: 0 } => Err(Error::InvalidQuorum),
    835             Self::RequiredTargets { targets } => {
    836                 validate_required_target_fingerprints(targets.as_slice())
    837             }
    838             Self::Any | Self::All | Self::Quorum { .. } => Ok(()),
    839         }
    840     }
    841 
    842     /// Returns the count required for delivery satisfaction.
    843     pub fn required_target_count(&self, target_count: usize) -> usize {
    844         match self {
    845             Self::Any => usize::from(target_count > 0),
    846             Self::All => target_count,
    847             Self::Quorum { quorum } => *quorum,
    848             Self::RequiredTargets { targets } => targets.len(),
    849         }
    850     }
    851 }
    852 
    853 impl EventRequest {
    854     /// Performs wire-structural validation without creating native targets.
    855     pub fn validate(&self, max_targets: usize) -> Result<(), Error> {
    856         if self.raw_event_json.is_empty() {
    857             return Err(Error::EmptyRawEventJson);
    858         }
    859         self.target_policy.validate_structure(max_targets)?;
    860         self.delivery_policy.validate()?;
    861         if self
    862             .idempotency_key
    863             .as_ref()
    864             .is_some_and(|key| key.trim().is_empty())
    865         {
    866             return Err(Error::EmptyIdempotencyKey);
    867         }
    868         if self.timeout_ms == Some(0) {
    869             return Err(Error::InvalidTimeoutMs);
    870         }
    871         Ok(())
    872     }
    873 }
    874 
    875 impl Job {
    876     /// Performs transport-neutral structural validation of a job receipt.
    877     pub fn validate(&self) -> Result<(), Error> {
    878         if self.job_id.trim().is_empty() {
    879             return Err(Error::EmptyJobId);
    880         }
    881         validate_lower_hex("event_id", self.event_id.as_str(), 64)?;
    882         validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?;
    883         self.target_policy.validate_structure(usize::MAX)?;
    884         self.delivery_policy.validate()?;
    885         if self.terminal != job_status_is_terminal(self.status) {
    886             return Err(Error::InvalidJobTerminalState);
    887         }
    888         if self.delivery_satisfied != (self.status == JobStatus::DeliverySatisfied) {
    889             return Err(Error::InvalidJobDeliverySatisfiedState);
    890         }
    891         let completed = job_status_has_completed_at(self.status);
    892         if self.completed_at_ms.is_some() != completed
    893             || self
    894                 .completed_at_ms
    895                 .is_some_and(|completed_at| completed_at < self.requested_at_ms)
    896         {
    897             return Err(Error::InvalidJobCompletedAt);
    898         }
    899         for (index, target) in self.targets.iter().enumerate() {
    900             validate_target_outcome(target, index)?;
    901         }
    902         validate_explicit_outcomes(&self.target_policy, self.targets.as_slice())?;
    903         if (!self.targets.is_empty() || completed) && self.target_count != self.targets.len() {
    904             return Err(Error::InvalidJobTargetCount {
    905                 expected: self.targets.len(),
    906                 actual: self.target_count,
    907             });
    908         }
    909 
    910         let acknowledged = self
    911             .targets
    912             .iter()
    913             .filter(|target| target.outcome_kind.counts_toward_accepted_delivery())
    914             .count();
    915         let retryable = self
    916             .targets
    917             .iter()
    918             .filter(|target| target.outcome_kind.is_retryable())
    919             .count();
    920         let terminal = self
    921             .targets
    922             .iter()
    923             .filter(|target| target.outcome_kind.is_terminal_failure())
    924             .count();
    925         if self.acknowledged_count != acknowledged {
    926             return Err(Error::InvalidJobAcknowledgedCount {
    927                 expected: acknowledged,
    928                 actual: self.acknowledged_count,
    929             });
    930         }
    931         if self.retryable_count != retryable {
    932             return Err(Error::InvalidJobRetryableCount {
    933                 expected: retryable,
    934                 actual: self.retryable_count,
    935             });
    936         }
    937         if self.terminal_count != terminal {
    938             return Err(Error::InvalidJobTerminalCount {
    939                 expected: terminal,
    940                 actual: self.terminal_count,
    941             });
    942         }
    943         validate_job_status(self, acknowledged, retryable, terminal)
    944     }
    945 }
    946 
    947 fn validate_endpoint(kind: &str, endpoint: &str, index: usize) -> Result<(), Error> {
    948     if endpoint.trim().is_empty() {
    949         return Err(Error::EmptyEndpointUri { index });
    950     }
    951     if endpoint != endpoint.trim()
    952         || endpoint
    953             .bytes()
    954             .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
    955     {
    956         return Err(Error::InvalidEndpointUri { index });
    957     }
    958     if kind == "reticulum" && endpoint != RETICULUM_ENDPOINT_URI {
    959         return Err(Error::InvalidReticulumEndpoint { index });
    960     }
    961     if kind == "nostr" && !(endpoint.starts_with("wss://") || endpoint.starts_with("ws://")) {
    962         return Err(Error::InvalidEndpointUri { index });
    963     }
    964     Ok(())
    965 }
    966 
    967 fn validate_target_metadata(
    968     scope: Option<&str>,
    969     label: Option<&str>,
    970     index: usize,
    971 ) -> Result<(), Error> {
    972     if let Some(scope) = scope {
    973         if scope.is_empty() {
    974             return Err(Error::EmptyTargetScope { index });
    975         }
    976         if scope != scope.trim()
    977             || scope
    978                 .bytes()
    979                 .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')))
    980         {
    981             return Err(Error::InvalidTargetScope { index });
    982         }
    983     }
    984     if let Some(label) = label {
    985         if label.trim().is_empty() {
    986             return Err(Error::EmptyTargetLabel { index });
    987         }
    988         if label != label.trim() || label.chars().any(char::is_control) {
    989             return Err(Error::InvalidTargetLabel { index });
    990         }
    991     }
    992     Ok(())
    993 }
    994 
    995 fn validate_required_target_fingerprints(targets: &[TargetFingerprint]) -> Result<(), Error> {
    996     if targets.is_empty() {
    997         return Err(Error::EmptyRequiredTargetSet);
    998     }
    999     let mut seen = BTreeSet::new();
   1000     for (index, target) in targets.iter().enumerate() {
   1001         if !seen.insert(target.as_str()) {
   1002             return Err(Error::DuplicateRequiredTargetFingerprint { index });
   1003         }
   1004     }
   1005     Ok(())
   1006 }
   1007 
   1008 fn validate_target_limit(target_count: usize, max_targets: usize) -> Result<(), Error> {
   1009     if target_count > max_targets {
   1010         Err(Error::TargetLimitExceeded {
   1011             max: max_targets,
   1012             actual: target_count,
   1013         })
   1014     } else {
   1015         Ok(())
   1016     }
   1017 }
   1018 
   1019 fn validate_lower_hex(field: &'static str, value: &str, expected_len: usize) -> Result<(), Error> {
   1020     if value.len() == expected_len
   1021         && value
   1022             .bytes()
   1023             .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
   1024     {
   1025         Ok(())
   1026     } else {
   1027         Err(Error::InvalidHexField {
   1028             field,
   1029             expected_len,
   1030         })
   1031     }
   1032 }
   1033 
   1034 fn job_status_is_terminal(status: JobStatus) -> bool {
   1035     matches!(
   1036         status,
   1037         JobStatus::DeliverySatisfied
   1038             | JobStatus::DeliveryUnsatisfiedTerminal
   1039             | JobStatus::DeliveryDeferred
   1040             | JobStatus::DeliveryDeferredUntilImplemented
   1041             | JobStatus::Rejected
   1042     )
   1043 }
   1044 
   1045 fn job_status_has_completed_at(status: JobStatus) -> bool {
   1046     !matches!(status, JobStatus::Accepted | JobStatus::Publishing)
   1047 }
   1048 
   1049 fn validate_target_outcome(target: &TargetOutcome, index: usize) -> Result<(), Error> {
   1050     Target {
   1051         transport_kind: target.transport_kind.clone(),
   1052         endpoint_uri: target.endpoint_uri.clone(),
   1053         target_scope: target.target_scope.clone(),
   1054         target_label: target.target_label.clone(),
   1055         reticulum_behavior: None,
   1056     }
   1057     .validate_structure(index)?;
   1058 
   1059     if target.transport_kind == "reticulum" {
   1060         if target.source != TargetSource::Reticulum
   1061             || target.attempted
   1062             || !target.outcome_kind.is_deferred_until_implemented()
   1063         {
   1064             return Err(Error::InvalidReticulumOutcome { index });
   1065         }
   1066     } else {
   1067         if target.source == TargetSource::Reticulum {
   1068             return Err(Error::InvalidTargetSource { index });
   1069         }
   1070         if target.outcome_kind.is_deferred_until_implemented() {
   1071             return Err(Error::InvalidTargetOutcomeKind { index });
   1072         }
   1073     }
   1074     Ok(())
   1075 }
   1076 
   1077 fn validate_explicit_outcomes(
   1078     policy: &TargetPolicy,
   1079     outcomes: &[TargetOutcome],
   1080 ) -> Result<(), Error> {
   1081     let TargetPolicy::ExplicitTargets { targets } = policy else {
   1082         return Ok(());
   1083     };
   1084     if outcomes.is_empty() {
   1085         return Ok(());
   1086     }
   1087     if targets.len() != outcomes.len() {
   1088         return Err(Error::InvalidExplicitTargetOutcome {
   1089             index: outcomes.len().min(targets.len()),
   1090         });
   1091     }
   1092     let mut matched = vec![false; targets.len()];
   1093     for (outcome_index, outcome) in outcomes.iter().enumerate() {
   1094         let Some(index) = targets.iter().enumerate().find_map(|(index, target)| {
   1095             (!matched[index] && target.same_wire_identity(outcome)).then_some(index)
   1096         }) else {
   1097             return Err(Error::InvalidExplicitTargetOutcome {
   1098                 index: outcome_index,
   1099             });
   1100         };
   1101         matched[index] = true;
   1102     }
   1103     Ok(())
   1104 }
   1105 
   1106 fn validate_job_status(
   1107     job: &Job,
   1108     acknowledged: usize,
   1109     retryable: usize,
   1110     terminal: usize,
   1111 ) -> Result<(), Error> {
   1112     if matches!(job.status, JobStatus::Accepted | JobStatus::Publishing) {
   1113         return Ok(());
   1114     }
   1115     if job.status == JobStatus::Rejected {
   1116         return (job.target_count == 0
   1117             && job.targets.is_empty()
   1118             && acknowledged == 0
   1119             && retryable == 0
   1120             && terminal == 0)
   1121             .then_some(())
   1122             .ok_or(Error::InvalidJobStatusState);
   1123     }
   1124     if job.targets.is_empty() {
   1125         return Err(Error::InvalidJobStatusState);
   1126     }
   1127     if matches!(job.delivery_policy, DeliveryPolicy::RequiredTargets { .. }) {
   1128         // Matching fingerprints to native targets is intentionally deferred to
   1129         // the transport conversion boundary. All other job invariants remain
   1130         // structural and are enforced above.
   1131         return Ok(());
   1132     }
   1133 
   1134     let satisfied = acknowledged >= job.delivery_policy.required_target_count(job.target_count);
   1135     let deferred = job
   1136         .targets
   1137         .iter()
   1138         .any(|target| target.outcome_kind == OutcomeKind::DeferredUntilImplemented);
   1139     let matches = if satisfied {
   1140         job.status == JobStatus::DeliverySatisfied
   1141     } else if retryable > 0 {
   1142         job.status == JobStatus::DeliveryUnsatisfiedRetryable
   1143     } else if terminal > 0 {
   1144         job.status == JobStatus::DeliveryUnsatisfiedTerminal
   1145     } else if deferred {
   1146         matches!(
   1147             job.status,
   1148             JobStatus::DeliveryDeferred | JobStatus::DeliveryDeferredUntilImplemented
   1149         )
   1150     } else {
   1151         false
   1152     };
   1153     matches.then_some(()).ok_or(Error::InvalidJobStatusState)
   1154 }
   1155 
   1156 /// Builds the schema registry for the V5 daemon transport-publish contract.
   1157 pub fn schema_registry() -> Result<Registry, crate::schema::Error> {
   1158     Registry::try_new([SchemaDescriptor::try_new(
   1159         API_VERSION,
   1160         ModuleVersion::RadrootsdTransportPublishV5,
   1161     )?])
   1162 }
   1163 
   1164 #[cfg(test)]
   1165 mod tests {
   1166     use super::*;
   1167 
   1168     fn request() -> EventRequest {
   1169         EventRequest {
   1170             raw_event_json: "{\"id\":\"event\"}".to_owned(),
   1171             target_policy: TargetPolicy::explicit_targets(vec![Target::nostr(
   1172                 "wss://relay.example.com",
   1173             )]),
   1174             delivery_policy: DeliveryPolicy::Any,
   1175             idempotency_key: Some("idem-1".to_owned()),
   1176             timeout_ms: Some(5_000),
   1177         }
   1178     }
   1179 
   1180     fn accepted_job() -> Job {
   1181         Job {
   1182             job_id: "job-1".to_owned(),
   1183             status: JobStatus::DeliverySatisfied,
   1184             terminal: true,
   1185             delivery_satisfied: true,
   1186             event_id: "0".repeat(64),
   1187             pubkey: "1".repeat(64),
   1188             event_kind: 30_402,
   1189             target_policy: TargetPolicy::explicit_targets(vec![Target::nostr(
   1190                 "wss://relay.example.com",
   1191             )]),
   1192             delivery_policy: DeliveryPolicy::Any,
   1193             target_count: 1,
   1194             acknowledged_count: 1,
   1195             retryable_count: 0,
   1196             terminal_count: 0,
   1197             requested_at_ms: 1,
   1198             completed_at_ms: Some(2),
   1199             last_error: None,
   1200             targets: vec![TargetOutcome {
   1201                 transport_kind: "nostr".to_owned(),
   1202                 endpoint_uri: "wss://relay.example.com".to_owned(),
   1203                 target_scope: None,
   1204                 target_label: None,
   1205                 source: TargetSource::Request,
   1206                 attempted: true,
   1207                 outcome_kind: OutcomeKind::Accepted,
   1208                 message: None,
   1209                 latency_ms: Some(7),
   1210             }],
   1211         }
   1212     }
   1213 
   1214     fn outcome(kind: OutcomeKind) -> TargetOutcome {
   1215         TargetOutcome {
   1216             transport_kind: "nostr".to_owned(),
   1217             endpoint_uri: "wss://relay.example.com".to_owned(),
   1218             target_scope: None,
   1219             target_label: None,
   1220             source: TargetSource::Request,
   1221             attempted: true,
   1222             outcome_kind: kind,
   1223             message: None,
   1224             latency_ms: None,
   1225         }
   1226     }
   1227 
   1228     fn completed_job(status: JobStatus, kind: OutcomeKind) -> Job {
   1229         let mut job = accepted_job();
   1230         job.status = status;
   1231         job.terminal = job_status_is_terminal(status);
   1232         job.delivery_satisfied = status == JobStatus::DeliverySatisfied;
   1233         job.targets = vec![outcome(kind)];
   1234         job.acknowledged_count = usize::from(kind.counts_toward_accepted_delivery());
   1235         job.retryable_count = usize::from(kind.is_retryable());
   1236         job.terminal_count = usize::from(kind.is_terminal_failure());
   1237         job
   1238     }
   1239 
   1240     #[test]
   1241     fn request_job_and_schema_registry_validate() {
   1242         request().validate(1).expect("request");
   1243         accepted_job().validate().expect("job");
   1244         let registry = schema_registry().expect("schema registry");
   1245         assert_eq!(registry.len(), 1);
   1246         assert_eq!(
   1247             registry.descriptors()[0].module(),
   1248             ModuleVersion::RadrootsdTransportPublishV5
   1249         );
   1250     }
   1251 
   1252     #[test]
   1253     fn structural_validation_rejects_invalid_fields() {
   1254         let mut invalid_timeout = request();
   1255         invalid_timeout.timeout_ms = Some(0);
   1256         assert_eq!(invalid_timeout.validate(1), Err(Error::InvalidTimeoutMs));
   1257 
   1258         let mut invalid_endpoint = request();
   1259         invalid_endpoint.target_policy =
   1260             TargetPolicy::explicit_targets(vec![Target::nostr("WSS://relay.example.com")]);
   1261         assert_eq!(
   1262             invalid_endpoint.validate(1),
   1263             Err(Error::InvalidEndpointUri { index: 0 })
   1264         );
   1265 
   1266         let mut job = accepted_job();
   1267         job.event_id = "ABC".repeat(21);
   1268         assert_eq!(
   1269             job.validate(),
   1270             Err(Error::InvalidHexField {
   1271                 field: "event_id",
   1272                 expected_len: 64,
   1273             })
   1274         );
   1275     }
   1276 
   1277     #[test]
   1278     fn errors_have_stable_human_readable_messages() {
   1279         let errors = [
   1280             Error::InvalidHexField {
   1281                 field: "id",
   1282                 expected_len: 64,
   1283             },
   1284             Error::EmptyRawEventJson,
   1285             Error::EmptyTag { index: 1 },
   1286             Error::EmptyIdempotencyKey,
   1287             Error::EmptyTransportKind { index: 2 },
   1288             Error::InvalidTransportKind { index: 3 },
   1289             Error::EmptyEndpointUri { index: 4 },
   1290             Error::InvalidEndpointUri { index: 5 },
   1291             Error::EmptyTargetScope { index: 6 },
   1292             Error::InvalidTargetScope { index: 7 },
   1293             Error::EmptyTargetLabel { index: 8 },
   1294             Error::InvalidTargetLabel { index: 9 },
   1295             Error::InvalidReticulumBehavior { index: 10 },
   1296             Error::InvalidTimeoutMs,
   1297             Error::InvalidReticulumEndpoint { index: 11 },
   1298             Error::DuplicateTarget { index: 12 },
   1299             Error::TargetLimitExceeded { max: 1, actual: 2 },
   1300             Error::EmptyTargetSet,
   1301             Error::InvalidQuorum,
   1302             Error::EmptyRequiredTargetSet,
   1303             Error::DuplicateRequiredTargetFingerprint { index: 13 },
   1304             Error::RequiredTargetNotInTargetSet { index: 14 },
   1305             Error::EmptyPrincipalId,
   1306             Error::EmptyJobId,
   1307             Error::InvalidJobTargetCount {
   1308                 expected: 1,
   1309                 actual: 2,
   1310             },
   1311             Error::InvalidJobAcknowledgedCount {
   1312                 expected: 1,
   1313                 actual: 2,
   1314             },
   1315             Error::InvalidJobRetryableCount {
   1316                 expected: 1,
   1317                 actual: 2,
   1318             },
   1319             Error::InvalidJobTerminalCount {
   1320                 expected: 1,
   1321                 actual: 2,
   1322             },
   1323             Error::InvalidJobTerminalState,
   1324             Error::InvalidJobDeliverySatisfiedState,
   1325             Error::InvalidJobCompletedAt,
   1326             Error::InvalidJobStatusState,
   1327             Error::InvalidExplicitTargetOutcome { index: 15 },
   1328             Error::InvalidTargetOutcomeKind { index: 16 },
   1329             Error::InvalidTargetSource { index: 17 },
   1330             Error::InvalidReticulumOutcome { index: 18 },
   1331         ];
   1332         for error in errors {
   1333             assert!(!error.to_string().is_empty());
   1334         }
   1335     }
   1336 
   1337     #[test]
   1338     fn target_and_request_validation_cover_every_structural_rule() {
   1339         let valid = Target::nostr("ws://relay.example.com")
   1340             .with_scope("farm.eu-1")
   1341             .with_label("Farm relay");
   1342         assert_eq!(valid.transport_kind, "nostr");
   1343         assert_eq!(valid.target_scope.as_deref(), Some("farm.eu-1"));
   1344         assert_eq!(valid.target_label.as_deref(), Some("Farm relay"));
   1345         assert!(valid.validate_structure(0).is_ok());
   1346         assert!(
   1347             Target::reticulum(ReticulumBehavior::DeferDeliveryPlans)
   1348                 .validate_structure(0)
   1349                 .is_ok()
   1350         );
   1351 
   1352         let invalid = [
   1353             (
   1354                 Target {
   1355                     transport_kind: String::new(),
   1356                     ..valid.clone()
   1357                 },
   1358                 Error::EmptyTransportKind { index: 0 },
   1359             ),
   1360             (
   1361                 Target {
   1362                     transport_kind: " \t".to_owned(),
   1363                     ..valid.clone()
   1364                 },
   1365                 Error::EmptyTransportKind { index: 0 },
   1366             ),
   1367             (
   1368                 Target {
   1369                     transport_kind: "NOSTR".to_owned(),
   1370                     ..valid.clone()
   1371                 },
   1372                 Error::InvalidTransportKind { index: 0 },
   1373             ),
   1374             (
   1375                 Target {
   1376                     endpoint_uri: String::new(),
   1377                     ..valid.clone()
   1378                 },
   1379                 Error::EmptyEndpointUri { index: 0 },
   1380             ),
   1381             (
   1382                 Target {
   1383                     endpoint_uri: " wss://relay.example.com".to_owned(),
   1384                     ..valid.clone()
   1385                 },
   1386                 Error::InvalidEndpointUri { index: 0 },
   1387             ),
   1388             (
   1389                 Target {
   1390                     endpoint_uri: "https://relay.example.com".to_owned(),
   1391                     ..valid.clone()
   1392                 },
   1393                 Error::InvalidEndpointUri { index: 0 },
   1394             ),
   1395             (
   1396                 Target {
   1397                     target_scope: Some(String::new()),
   1398                     ..valid.clone()
   1399                 },
   1400                 Error::EmptyTargetScope { index: 0 },
   1401             ),
   1402             (
   1403                 Target {
   1404                     target_scope: Some("bad scope".to_owned()),
   1405                     ..valid.clone()
   1406                 },
   1407                 Error::InvalidTargetScope { index: 0 },
   1408             ),
   1409             (
   1410                 Target {
   1411                     target_scope: Some(" scope".to_owned()),
   1412                     ..valid.clone()
   1413                 },
   1414                 Error::InvalidTargetScope { index: 0 },
   1415             ),
   1416             (
   1417                 Target {
   1418                     target_label: Some(" \t".to_owned()),
   1419                     ..valid.clone()
   1420                 },
   1421                 Error::EmptyTargetLabel { index: 0 },
   1422             ),
   1423             (
   1424                 Target {
   1425                     target_label: Some(" label".to_owned()),
   1426                     ..valid.clone()
   1427                 },
   1428                 Error::InvalidTargetLabel { index: 0 },
   1429             ),
   1430             (
   1431                 Target {
   1432                     target_label: Some("bad\nlabel".to_owned()),
   1433                     ..valid.clone()
   1434                 },
   1435                 Error::InvalidTargetLabel { index: 0 },
   1436             ),
   1437             (
   1438                 Target {
   1439                     reticulum_behavior: Some(ReticulumBehavior::RejectDeliveryAttempts),
   1440                     ..valid.clone()
   1441                 },
   1442                 Error::InvalidReticulumBehavior { index: 0 },
   1443             ),
   1444             (
   1445                 Target {
   1446                     transport_kind: "reticulum".to_owned(),
   1447                     endpoint_uri: "reticulum:other".to_owned(),
   1448                     target_scope: None,
   1449                     target_label: None,
   1450                     reticulum_behavior: None,
   1451                 },
   1452                 Error::InvalidReticulumEndpoint { index: 0 },
   1453             ),
   1454         ];
   1455         for (target, error) in invalid {
   1456             assert_eq!(target.validate_structure(0), Err(error));
   1457         }
   1458 
   1459         let mut empty = request();
   1460         empty.raw_event_json.clear();
   1461         assert_eq!(empty.validate(1), Err(Error::EmptyRawEventJson));
   1462         let mut no_targets = request();
   1463         no_targets.target_policy = TargetPolicy::explicit_targets(vec![]);
   1464         assert_eq!(no_targets.validate(1), Err(Error::EmptyTargetSet));
   1465         let mut too_many = request();
   1466         too_many.target_policy = TargetPolicy::explicit_targets(vec![
   1467             valid.clone(),
   1468             Target::nostr("wss://second.example.com"),
   1469         ]);
   1470         assert_eq!(
   1471             too_many.validate(1),
   1472             Err(Error::TargetLimitExceeded { max: 1, actual: 2 })
   1473         );
   1474         let mut duplicate = request();
   1475         duplicate.target_policy = TargetPolicy::explicit_targets(vec![valid.clone(), valid]);
   1476         assert_eq!(
   1477             duplicate.validate(2),
   1478             Err(Error::DuplicateTarget { index: 1 })
   1479         );
   1480         let mut nostr = request();
   1481         nostr.target_policy = TargetPolicy::nostr(
   1482             NostrTargetSourcePolicy::DaemonDefaultOnly,
   1483             vec!["wss://a.example".to_owned(), "wss://a.example".to_owned()],
   1484         );
   1485         assert_eq!(nostr.target_policy.request_target_count(), 2);
   1486         assert_eq!(nostr.validate(2), Err(Error::DuplicateTarget { index: 1 }));
   1487         nostr.target_policy = TargetPolicy::nostr(
   1488             NostrTargetSourcePolicy::ExplicitOnly,
   1489             vec!["https://bad.example".to_owned()],
   1490         );
   1491         assert_eq!(
   1492             nostr.validate(1),
   1493             Err(Error::InvalidEndpointUri { index: 0 })
   1494         );
   1495         let mut blank_key = request();
   1496         blank_key.idempotency_key = Some(" \t".to_owned());
   1497         assert_eq!(blank_key.validate(1), Err(Error::EmptyIdempotencyKey));
   1498     }
   1499 
   1500     #[test]
   1501     fn delivery_policy_and_outcome_classifications_are_exhaustive() {
   1502         let fingerprint = TargetFingerprint::parse("a".repeat(64)).expect("fingerprint");
   1503         assert_eq!(fingerprint.as_str(), "a".repeat(64));
   1504         assert!(TargetFingerprint::parse("A".repeat(64)).is_err());
   1505         assert_eq!(
   1506             DeliveryPolicy::required_targets(vec![]),
   1507             Err(Error::EmptyRequiredTargetSet)
   1508         );
   1509         assert_eq!(
   1510             DeliveryPolicy::required_targets(vec![fingerprint.clone(), fingerprint.clone()]),
   1511             Err(Error::DuplicateRequiredTargetFingerprint { index: 1 })
   1512         );
   1513         let required = DeliveryPolicy::required_targets(vec![fingerprint]).expect("required");
   1514         assert_eq!(required.required_target_count(9), 1);
   1515         assert!(required.validate().is_ok());
   1516         assert_eq!(
   1517             DeliveryPolicy::Quorum { quorum: 0 }.validate(),
   1518             Err(Error::InvalidQuorum)
   1519         );
   1520         assert!(DeliveryPolicy::Any.validate().is_ok());
   1521         assert!(DeliveryPolicy::All.validate().is_ok());
   1522         assert!(DeliveryPolicy::Quorum { quorum: 2 }.validate().is_ok());
   1523         assert_eq!(DeliveryPolicy::Any.required_target_count(0), 0);
   1524         assert_eq!(DeliveryPolicy::Any.required_target_count(2), 1);
   1525         assert_eq!(DeliveryPolicy::All.required_target_count(2), 2);
   1526         assert_eq!(
   1527             DeliveryPolicy::Quorum { quorum: 2 }.required_target_count(9),
   1528             2
   1529         );
   1530 
   1531         for kind in [
   1532             OutcomeKind::Accepted,
   1533             OutcomeKind::DuplicateAccepted,
   1534             OutcomeKind::SkippedAlreadyAccepted,
   1535         ] {
   1536             assert!(kind.counts_toward_accepted_delivery());
   1537             assert!(!kind.is_retryable());
   1538             assert!(!kind.is_terminal_failure());
   1539         }
   1540         for kind in [
   1541             OutcomeKind::RateLimited,
   1542             OutcomeKind::PowRequired,
   1543             OutcomeKind::AuthRequired,
   1544             OutcomeKind::Error,
   1545             OutcomeKind::Timeout,
   1546             OutcomeKind::ConnectionFailed,
   1547             OutcomeKind::Unknown,
   1548         ] {
   1549             assert!(kind.is_retryable());
   1550             assert!(!kind.counts_toward_accepted_delivery());
   1551         }
   1552         for kind in [
   1553             OutcomeKind::Blocked,
   1554             OutcomeKind::Invalid,
   1555             OutcomeKind::Restricted,
   1556             OutcomeKind::Muted,
   1557             OutcomeKind::Unsupported,
   1558             OutcomeKind::PaymentRequired,
   1559             OutcomeKind::TargetRejected,
   1560         ] {
   1561             assert!(kind.is_terminal_failure());
   1562             assert!(!kind.is_retryable());
   1563         }
   1564         assert!(OutcomeKind::DeferredUntilImplemented.is_deferred_until_implemented());
   1565     }
   1566 
   1567     #[test]
   1568     fn job_validation_covers_counts_lifecycle_and_transport_rules() {
   1569         for status in [JobStatus::Accepted, JobStatus::Publishing] {
   1570             let mut job = accepted_job();
   1571             job.status = status;
   1572             job.terminal = false;
   1573             job.delivery_satisfied = false;
   1574             job.completed_at_ms = None;
   1575             assert!(job.validate().is_ok());
   1576         }
   1577         for (status, kind) in [
   1578             (JobStatus::DeliverySatisfied, OutcomeKind::Accepted),
   1579             (
   1580                 JobStatus::DeliveryUnsatisfiedRetryable,
   1581                 OutcomeKind::Timeout,
   1582             ),
   1583             (JobStatus::DeliveryUnsatisfiedTerminal, OutcomeKind::Blocked),
   1584         ] {
   1585             assert!(completed_job(status, kind).validate().is_ok());
   1586         }
   1587         let mut deferred = completed_job(
   1588             JobStatus::DeliveryDeferred,
   1589             OutcomeKind::DeferredUntilImplemented,
   1590         );
   1591         deferred.target_policy = TargetPolicy::explicit_targets(vec![Target::reticulum(
   1592             ReticulumBehavior::DeferDeliveryPlans,
   1593         )]);
   1594         deferred.targets[0] = TargetOutcome {
   1595             transport_kind: "reticulum".to_owned(),
   1596             endpoint_uri: RETICULUM_ENDPOINT_URI.to_owned(),
   1597             source: TargetSource::Reticulum,
   1598             attempted: false,
   1599             outcome_kind: OutcomeKind::DeferredUntilImplemented,
   1600             ..outcome(OutcomeKind::DeferredUntilImplemented)
   1601         };
   1602         assert!(deferred.validate().is_ok());
   1603         deferred.status = JobStatus::DeliveryDeferredUntilImplemented;
   1604         assert!(deferred.validate().is_ok());
   1605 
   1606         let mut rejected = accepted_job();
   1607         rejected.status = JobStatus::Rejected;
   1608         rejected.terminal = true;
   1609         rejected.delivery_satisfied = false;
   1610         rejected.target_policy =
   1611             TargetPolicy::nostr(NostrTargetSourcePolicy::DaemonDefaultOnly, vec![]);
   1612         rejected.target_count = 0;
   1613         rejected.acknowledged_count = 0;
   1614         rejected.targets.clear();
   1615         assert!(rejected.validate().is_ok());
   1616 
   1617         let mut cases = Vec::new();
   1618         let mut job = accepted_job();
   1619         job.job_id = " ".to_owned();
   1620         cases.push((job, Error::EmptyJobId));
   1621         let mut job = accepted_job();
   1622         job.pubkey = "g".repeat(64);
   1623         cases.push((
   1624             job,
   1625             Error::InvalidHexField {
   1626                 field: "pubkey",
   1627                 expected_len: 64,
   1628             },
   1629         ));
   1630         let mut job = accepted_job();
   1631         job.terminal = false;
   1632         cases.push((job, Error::InvalidJobTerminalState));
   1633         let mut job = accepted_job();
   1634         job.delivery_satisfied = false;
   1635         cases.push((job, Error::InvalidJobDeliverySatisfiedState));
   1636         let mut job = accepted_job();
   1637         job.completed_at_ms = None;
   1638         cases.push((job, Error::InvalidJobCompletedAt));
   1639         let mut job = accepted_job();
   1640         job.completed_at_ms = Some(0);
   1641         cases.push((job, Error::InvalidJobCompletedAt));
   1642         let mut job = accepted_job();
   1643         job.target_count = 2;
   1644         cases.push((
   1645             job,
   1646             Error::InvalidJobTargetCount {
   1647                 expected: 1,
   1648                 actual: 2,
   1649             },
   1650         ));
   1651         let mut job = accepted_job();
   1652         job.acknowledged_count = 0;
   1653         cases.push((
   1654             job,
   1655             Error::InvalidJobAcknowledgedCount {
   1656                 expected: 1,
   1657                 actual: 0,
   1658             },
   1659         ));
   1660         let mut job = completed_job(
   1661             JobStatus::DeliveryUnsatisfiedRetryable,
   1662             OutcomeKind::Timeout,
   1663         );
   1664         job.retryable_count = 0;
   1665         cases.push((
   1666             job,
   1667             Error::InvalidJobRetryableCount {
   1668                 expected: 1,
   1669                 actual: 0,
   1670             },
   1671         ));
   1672         let mut job = completed_job(JobStatus::DeliveryUnsatisfiedTerminal, OutcomeKind::Blocked);
   1673         job.terminal_count = 0;
   1674         cases.push((
   1675             job,
   1676             Error::InvalidJobTerminalCount {
   1677                 expected: 1,
   1678                 actual: 0,
   1679             },
   1680         ));
   1681         for (job, error) in cases {
   1682             assert_eq!(job.validate(), Err(error));
   1683         }
   1684 
   1685         let mut invalid = accepted_job();
   1686         invalid.targets[0].source = TargetSource::Reticulum;
   1687         assert_eq!(
   1688             invalid.validate(),
   1689             Err(Error::InvalidTargetSource { index: 0 })
   1690         );
   1691         let mut invalid = accepted_job();
   1692         invalid.targets[0].outcome_kind = OutcomeKind::DeferredUntilImplemented;
   1693         assert_eq!(
   1694             invalid.validate(),
   1695             Err(Error::InvalidTargetOutcomeKind { index: 0 })
   1696         );
   1697         let mut invalid = deferred.clone();
   1698         invalid.targets[0].attempted = true;
   1699         assert_eq!(
   1700             invalid.validate(),
   1701             Err(Error::InvalidReticulumOutcome { index: 0 })
   1702         );
   1703         let mut invalid = accepted_job();
   1704         invalid.targets[0].endpoint_uri = "wss://other.example".to_owned();
   1705         assert_eq!(
   1706             invalid.validate(),
   1707             Err(Error::InvalidExplicitTargetOutcome { index: 0 })
   1708         );
   1709         let mut invalid = accepted_job();
   1710         invalid.targets.push(outcome(OutcomeKind::Accepted));
   1711         assert_eq!(
   1712             invalid.validate(),
   1713             Err(Error::InvalidExplicitTargetOutcome { index: 1 })
   1714         );
   1715         let mut invalid = completed_job(JobStatus::DeliverySatisfied, OutcomeKind::Blocked);
   1716         invalid.delivery_satisfied = true;
   1717         assert_eq!(invalid.validate(), Err(Error::InvalidJobStatusState));
   1718     }
   1719 
   1720     #[cfg(feature = "serde")]
   1721     #[test]
   1722     fn json_vectors_preserve_v5_names_and_unknown_fields_fail_closed() {
   1723         let encoded = serde_json::to_value(request()).expect("request JSON");
   1724         assert_eq!(encoded["target_policy"]["kind"], "explicit_targets");
   1725         assert_eq!(encoded["delivery_policy"]["mode"], "any");
   1726         let mut object = encoded.as_object().expect("request object").clone();
   1727         object.insert("unknown".to_owned(), serde_json::json!(true));
   1728         assert!(serde_json::from_value::<EventRequest>(object.into()).is_err());
   1729 
   1730         assert_eq!(
   1731             serde_json::to_string(&ReticulumBehavior::RejectDeliveryAttempts)
   1732                 .expect("behavior JSON"),
   1733             "\"reject_delivery_attempts\""
   1734         );
   1735         let capabilities = Capabilities::v5(1_024, 10);
   1736         assert_eq!(capabilities.api_version, API_VERSION);
   1737         assert_eq!(
   1738             capabilities.publish.transports[1].message,
   1739             RETICULUM_UNAVAILABLE_MESSAGE
   1740         );
   1741     }
   1742 }