rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

publication_attempt.rs (18747B)


      1 //! Closed publication target states and bounded attempt evidence.
      2 
      3 use core::fmt;
      4 use std::error::Error;
      5 
      6 use sha2::{Digest as _, Sha256};
      7 
      8 use crate::RhiCommittedPublication;
      9 
     10 /// Exact version of the publication-attempt evidence contract.
     11 pub const RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION: u32 = 1;
     12 
     13 /// Absolute target ordinal ceiling inherited from the 32-target publication bound.
     14 pub const RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM: u32 = 31;
     15 
     16 /// Absolute durable attempt ceiling inherited from the publication contract.
     17 pub const RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM: u16 = 100;
     18 
     19 const ATTEMPT_ID_DOMAIN: &[u8] = b"radroots.rhi.publication_attempt.v1\0";
     20 const MAX_UNIX_MILLISECONDS: u64 = i64::MAX as u64;
     21 
     22 /// Stable closed target state retained by the publication workflow.
     23 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
     24 pub enum RhiPublicationTargetState {
     25     Pending,
     26     Submitted,
     27     Accepted,
     28     Rejected,
     29     RateLimited,
     30     AuthRequired,
     31     Failed,
     32     Unknown,
     33 }
     34 
     35 impl RhiPublicationTargetState {
     36     /// Returns the exact machine-contract spelling.
     37     #[must_use]
     38     pub const fn code(self) -> &'static str {
     39         match self {
     40             Self::Pending => "pending",
     41             Self::Submitted => "submitted",
     42             Self::Accepted => "accepted",
     43             Self::Rejected => "rejected",
     44             Self::RateLimited => "rate_limited",
     45             Self::AuthRequired => "auth_required",
     46             Self::Failed => "failed",
     47             Self::Unknown => "unknown",
     48         }
     49     }
     50 
     51     /// Reports whether the state is the sole terminal target state.
     52     #[must_use]
     53     pub const fn is_accepted_terminal(self) -> bool {
     54         matches!(self, Self::Accepted)
     55     }
     56 }
     57 
     58 /// Stable closed outcome for one bounded publication attempt.
     59 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
     60 pub enum RhiPublicationAttemptOutcome {
     61     Submitted,
     62     Accepted,
     63     Rejected,
     64     RateLimited,
     65     AuthRequired,
     66     Failed,
     67     Unknown,
     68 }
     69 
     70 impl RhiPublicationAttemptOutcome {
     71     /// Returns the exact machine-contract spelling and safe persisted result code.
     72     #[must_use]
     73     pub const fn code(self) -> &'static str {
     74         match self {
     75             Self::Submitted => "submitted",
     76             Self::Accepted => "accepted",
     77             Self::Rejected => "rejected",
     78             Self::RateLimited => "rate_limited",
     79             Self::AuthRequired => "auth_required",
     80             Self::Failed => "failed",
     81             Self::Unknown => "unknown",
     82         }
     83     }
     84 
     85     /// Returns the target state represented by this exact observation.
     86     #[must_use]
     87     pub const fn target_state(self) -> RhiPublicationTargetState {
     88         match self {
     89             Self::Submitted => RhiPublicationTargetState::Submitted,
     90             Self::Accepted => RhiPublicationTargetState::Accepted,
     91             Self::Rejected => RhiPublicationTargetState::Rejected,
     92             Self::RateLimited => RhiPublicationTargetState::RateLimited,
     93             Self::AuthRequired => RhiPublicationTargetState::AuthRequired,
     94             Self::Failed => RhiPublicationTargetState::Failed,
     95             Self::Unknown => RhiPublicationTargetState::Unknown,
     96         }
     97     }
     98 }
     99 
    100 /// Bounded injected wall-clock value in integer UTC milliseconds.
    101 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    102 pub struct RhiPublicationUnixMilliseconds(pub(crate) u64);
    103 
    104 impl RhiPublicationUnixMilliseconds {
    105     /// Validates an injected timestamp against SQLite's signed representation.
    106     pub fn new(value: u64) -> Result<Self, RhiPublicationAttemptEvidenceError> {
    107         if value > MAX_UNIX_MILLISECONDS {
    108             return Err(failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTime));
    109         }
    110         Ok(Self(value))
    111     }
    112 
    113     /// Returns the exact integer UTC millisecond value.
    114     #[must_use]
    115     pub const fn get(self) -> u64 {
    116         self.0
    117     }
    118 }
    119 
    120 /// Domain-separated identity of one exact outbox-target attempt.
    121 #[derive(Clone, Copy, PartialEq, Eq, Hash)]
    122 pub struct RhiPublicationAttemptId(pub(crate) [u8; 32]);
    123 
    124 impl RhiPublicationAttemptId {
    125     /// Returns the exact identity bytes.
    126     #[must_use]
    127     pub const fn as_bytes(&self) -> &[u8; 32] {
    128         &self.0
    129     }
    130 }
    131 
    132 pub(crate) const fn attempt_id_from_durable_bytes(bytes: [u8; 32]) -> RhiPublicationAttemptId {
    133     RhiPublicationAttemptId(bytes)
    134 }
    135 
    136 pub(crate) fn derive_attempt_id(
    137     outbox_id: crate::RhiPublicationOutboxId,
    138     event_sha256: [u8; 32],
    139     target_ordinal: u8,
    140     attempt_number: u16,
    141 ) -> RhiPublicationAttemptId {
    142     let mut digest = Sha256::new();
    143     digest.update(ATTEMPT_ID_DOMAIN);
    144     digest.update(outbox_id.as_bytes());
    145     digest.update(event_sha256);
    146     digest.update(u32::from(target_ordinal).to_be_bytes());
    147     digest.update(u32::from(attempt_number).to_be_bytes());
    148     RhiPublicationAttemptId(digest.finalize().into())
    149 }
    150 
    151 impl fmt::Debug for RhiPublicationAttemptId {
    152     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    153         formatter.write_str("RhiPublicationAttemptId([redacted])")
    154     }
    155 }
    156 
    157 /// Sealed bounded evidence for one exact publication attempt.
    158 ///
    159 /// Construction binds the immutable committed outbox and event digest to a
    160 /// bounded target ordinal and attempt number. It does not claim the target,
    161 /// persist a Submitted transition, perform relay I/O, or authorize a later
    162 /// durable outcome transition; Step 202 must revalidate those live facts.
    163 ///
    164 /// ```compile_fail
    165 /// use rhi::RhiPublicationAttemptEvidence;
    166 ///
    167 /// let _forged = RhiPublicationAttemptEvidence { attempt_number: 1 };
    168 /// ```
    169 #[must_use = "publication attempt evidence must be durably reconciled or deliberately discarded"]
    170 pub struct RhiPublicationAttemptEvidence {
    171     id: RhiPublicationAttemptId,
    172     outbox_id: crate::RhiPublicationOutboxId,
    173     event_sha256: [u8; 32],
    174     target_ordinal: u8,
    175     attempt_number: u16,
    176     started_at: RhiPublicationUnixMilliseconds,
    177     finished_at: RhiPublicationUnixMilliseconds,
    178     outcome: RhiPublicationAttemptOutcome,
    179 }
    180 
    181 impl RhiPublicationAttemptEvidence {
    182     /// Constructs one bounded observation from the exact committed publication.
    183     pub fn new(
    184         publication: &RhiCommittedPublication,
    185         target_ordinal: u32,
    186         attempt_number: u16,
    187         started_at: RhiPublicationUnixMilliseconds,
    188         finished_at: RhiPublicationUnixMilliseconds,
    189         outcome: RhiPublicationAttemptOutcome,
    190     ) -> Result<Self, RhiPublicationAttemptEvidenceError> {
    191         let target_ordinal = u8::try_from(target_ordinal)
    192             .ok()
    193             .filter(|ordinal| u32::from(*ordinal) <= RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM)
    194             .ok_or_else(|| failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal))?;
    195         if !(1..=RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM).contains(&attempt_number) {
    196             return Err(failure(
    197                 RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber,
    198             ));
    199         }
    200         if finished_at < started_at {
    201             return Err(failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTime));
    202         }
    203         let outbox_id = publication.outbox_id();
    204         let event_sha256 = *publication.event_sha256();
    205         Ok(Self {
    206             id: derive_attempt_id(outbox_id, event_sha256, target_ordinal, attempt_number),
    207             outbox_id,
    208             event_sha256,
    209             target_ordinal,
    210             attempt_number,
    211             started_at,
    212             finished_at,
    213             outcome,
    214         })
    215     }
    216 
    217     /// Returns the exact domain-separated attempt identity.
    218     #[must_use]
    219     pub const fn id(&self) -> RhiPublicationAttemptId {
    220         self.id
    221     }
    222 
    223     /// Returns the immutable committed outbox identity.
    224     #[must_use]
    225     pub const fn outbox_id(&self) -> crate::RhiPublicationOutboxId {
    226         self.outbox_id
    227     }
    228 
    229     /// Returns the digest of the exact committed signed-event bytes.
    230     #[must_use]
    231     pub const fn event_sha256(&self) -> &[u8; 32] {
    232         &self.event_sha256
    233     }
    234 
    235     /// Returns the bounded zero-based target ordinal.
    236     #[must_use]
    237     pub const fn target_ordinal(&self) -> u8 {
    238         self.target_ordinal
    239     }
    240 
    241     /// Returns the bounded one-based attempt number.
    242     #[must_use]
    243     pub const fn attempt_number(&self) -> u16 {
    244         self.attempt_number
    245     }
    246 
    247     /// Returns the injected attempt start time.
    248     #[must_use]
    249     pub const fn started_at(&self) -> RhiPublicationUnixMilliseconds {
    250         self.started_at
    251     }
    252 
    253     /// Returns the injected attempt finish time.
    254     #[must_use]
    255     pub const fn finished_at(&self) -> RhiPublicationUnixMilliseconds {
    256         self.finished_at
    257     }
    258 
    259     /// Returns the closed observed outcome.
    260     #[must_use]
    261     pub const fn outcome(&self) -> RhiPublicationAttemptOutcome {
    262         self.outcome
    263     }
    264 
    265     /// Returns the sole safe persisted result code for this outcome.
    266     #[must_use]
    267     pub const fn result_code(&self) -> &'static str {
    268         self.outcome.code()
    269     }
    270 }
    271 
    272 impl fmt::Debug for RhiPublicationAttemptEvidence {
    273     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    274         formatter
    275             .debug_struct("RhiPublicationAttemptEvidence")
    276             .field("identity", &"[redacted]")
    277             .field("target_ordinal", &self.target_ordinal)
    278             .field("attempt_number", &self.attempt_number)
    279             .field("started_at", &self.started_at)
    280             .field("finished_at", &self.finished_at)
    281             .field("outcome", &self.outcome)
    282             .finish()
    283     }
    284 }
    285 
    286 /// Stable source-free attempt-evidence failure class.
    287 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    288 pub enum RhiPublicationAttemptEvidenceErrorKind {
    289     InvalidTargetOrdinal,
    290     InvalidAttemptNumber,
    291     InvalidTime,
    292 }
    293 
    294 impl RhiPublicationAttemptEvidenceErrorKind {
    295     /// Returns the stable machine-readable failure code.
    296     #[must_use]
    297     pub const fn code(self) -> &'static str {
    298         match self {
    299             Self::InvalidTargetOrdinal => "publication_attempt_target_ordinal_invalid",
    300             Self::InvalidAttemptNumber => "publication_attempt_number_invalid",
    301             Self::InvalidTime => "publication_attempt_time_invalid",
    302         }
    303     }
    304 }
    305 
    306 /// Redacted source-free attempt-evidence failure.
    307 #[derive(Clone, Copy, PartialEq, Eq)]
    308 pub struct RhiPublicationAttemptEvidenceError {
    309     kind: RhiPublicationAttemptEvidenceErrorKind,
    310 }
    311 
    312 impl RhiPublicationAttemptEvidenceError {
    313     /// Returns the stable failure class.
    314     #[must_use]
    315     pub const fn kind(self) -> RhiPublicationAttemptEvidenceErrorKind {
    316         self.kind
    317     }
    318 
    319     /// Returns the stable machine-readable failure code.
    320     #[must_use]
    321     pub const fn code(self) -> &'static str {
    322         self.kind.code()
    323     }
    324 }
    325 
    326 impl fmt::Display for RhiPublicationAttemptEvidenceError {
    327     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    328         formatter.write_str(match self.kind {
    329             RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal => {
    330                 "RHI publication attempt target ordinal is invalid"
    331             }
    332             RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber => {
    333                 "RHI publication attempt number is invalid"
    334             }
    335             RhiPublicationAttemptEvidenceErrorKind::InvalidTime => {
    336                 "RHI publication attempt time is invalid"
    337             }
    338         })
    339     }
    340 }
    341 
    342 impl fmt::Debug for RhiPublicationAttemptEvidenceError {
    343     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    344         formatter
    345             .debug_struct("RhiPublicationAttemptEvidenceError")
    346             .field("kind", &self.kind)
    347             .finish()
    348     }
    349 }
    350 
    351 impl Error for RhiPublicationAttemptEvidenceError {}
    352 
    353 const fn failure(
    354     kind: RhiPublicationAttemptEvidenceErrorKind,
    355 ) -> RhiPublicationAttemptEvidenceError {
    356     RhiPublicationAttemptEvidenceError { kind }
    357 }
    358 
    359 #[cfg(test)]
    360 mod tests {
    361     use super::*;
    362 
    363     fn publication() -> RhiCommittedPublication {
    364         RhiCommittedPublication::test_fixture(
    365             crate::RhiPublicationOutboxId::from_committed_bytes([0x11; 32]),
    366             [0x44; 32],
    367             [0x22; 32],
    368             vec![0x33].into_boxed_slice(),
    369         )
    370     }
    371 
    372     fn lower_hex(bytes: &[u8]) -> String {
    373         const DIGITS: &[u8; 16] = b"0123456789abcdef";
    374         let mut output = String::with_capacity(bytes.len() * 2);
    375         for byte in bytes {
    376             output.push(char::from(DIGITS[usize::from(byte >> 4)]));
    377             output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
    378         }
    379         output
    380     }
    381 
    382     #[test]
    383     fn states_and_outcomes_are_exact() {
    384         let states = [
    385             RhiPublicationTargetState::Pending,
    386             RhiPublicationTargetState::Submitted,
    387             RhiPublicationTargetState::Accepted,
    388             RhiPublicationTargetState::Rejected,
    389             RhiPublicationTargetState::RateLimited,
    390             RhiPublicationTargetState::AuthRequired,
    391             RhiPublicationTargetState::Failed,
    392             RhiPublicationTargetState::Unknown,
    393         ];
    394         assert_eq!(
    395             states.map(RhiPublicationTargetState::code),
    396             [
    397                 "pending",
    398                 "submitted",
    399                 "accepted",
    400                 "rejected",
    401                 "rate_limited",
    402                 "auth_required",
    403                 "failed",
    404                 "unknown",
    405             ]
    406         );
    407         assert!(RhiPublicationTargetState::Accepted.is_accepted_terminal());
    408         for state in states {
    409             assert_eq!(state.is_accepted_terminal(), state.code() == "accepted");
    410         }
    411         let outcomes = [
    412             RhiPublicationAttemptOutcome::Submitted,
    413             RhiPublicationAttemptOutcome::Accepted,
    414             RhiPublicationAttemptOutcome::Rejected,
    415             RhiPublicationAttemptOutcome::RateLimited,
    416             RhiPublicationAttemptOutcome::AuthRequired,
    417             RhiPublicationAttemptOutcome::Failed,
    418             RhiPublicationAttemptOutcome::Unknown,
    419         ];
    420         assert_eq!(
    421             outcomes.map(RhiPublicationAttemptOutcome::code),
    422             [
    423                 "submitted",
    424                 "accepted",
    425                 "rejected",
    426                 "rate_limited",
    427                 "auth_required",
    428                 "failed",
    429                 "unknown",
    430             ]
    431         );
    432         for outcome in outcomes {
    433             assert_eq!(outcome.code(), outcome.target_state().code());
    434         }
    435     }
    436 
    437     #[test]
    438     fn time_and_error_boundaries_are_safe() {
    439         assert_eq!(RhiPublicationUnixMilliseconds::new(0).unwrap().get(), 0);
    440         assert_eq!(
    441             RhiPublicationUnixMilliseconds::new(MAX_UNIX_MILLISECONDS)
    442                 .unwrap()
    443                 .get(),
    444             MAX_UNIX_MILLISECONDS
    445         );
    446         let error = RhiPublicationUnixMilliseconds::new(MAX_UNIX_MILLISECONDS + 1)
    447             .expect_err("oversized time");
    448         assert_eq!(
    449             error.kind(),
    450             RhiPublicationAttemptEvidenceErrorKind::InvalidTime
    451         );
    452         for kind in [
    453             RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal,
    454             RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber,
    455             RhiPublicationAttemptEvidenceErrorKind::InvalidTime,
    456         ] {
    457             let error = failure(kind);
    458             assert!(error.code().starts_with("publication_attempt_"));
    459             assert!(Error::source(&error).is_none());
    460             let rendered = format!("{error} {error:?}");
    461             assert!(!rendered.contains("relay-primary"));
    462             assert!(!rendered.contains("secret"));
    463         }
    464     }
    465 
    466     #[test]
    467     fn attempt_evidence_binds_exact_maximum_fields_and_closed_result() {
    468         let started_at = RhiPublicationUnixMilliseconds::new(42).unwrap();
    469         let finished_at = RhiPublicationUnixMilliseconds::new(43).unwrap();
    470         let evidence = RhiPublicationAttemptEvidence::new(
    471             &publication(),
    472             RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM,
    473             RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM,
    474             started_at,
    475             finished_at,
    476             RhiPublicationAttemptOutcome::RateLimited,
    477         )
    478         .expect("maximum evidence");
    479         assert_eq!(
    480             lower_hex(evidence.id().as_bytes()),
    481             "e1acaadff3f4d52ca7e9a8d14026039827bf8386ebb66551402dfd1d7309899c"
    482         );
    483         assert_eq!(
    484             evidence.outbox_id().as_bytes(),
    485             crate::RhiPublicationOutboxId::from_committed_bytes([0x11; 32]).as_bytes()
    486         );
    487         assert_eq!(evidence.event_sha256(), &[0x22; 32]);
    488         assert_eq!(evidence.target_ordinal(), 31);
    489         assert_eq!(evidence.attempt_number(), 100);
    490         assert_eq!(evidence.started_at(), started_at);
    491         assert_eq!(evidence.finished_at(), finished_at);
    492         assert_eq!(
    493             evidence.outcome(),
    494             RhiPublicationAttemptOutcome::RateLimited
    495         );
    496         assert_eq!(evidence.result_code(), "rate_limited");
    497         let rendered = format!("{evidence:?} {:?}", evidence.id());
    498         assert!(!rendered.contains(&lower_hex(evidence.id().as_bytes())));
    499         assert!(!rendered.contains("relay-primary"));
    500         assert!(!rendered.contains("3333"));
    501     }
    502 
    503     #[test]
    504     fn attempt_evidence_rejects_each_invalid_boundary() {
    505         let started_at = RhiPublicationUnixMilliseconds::new(42).unwrap();
    506         let finished_at = RhiPublicationUnixMilliseconds::new(43).unwrap();
    507         let cases = [
    508             (
    509                 32,
    510                 1,
    511                 started_at,
    512                 finished_at,
    513                 RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal,
    514             ),
    515             (
    516                 0,
    517                 0,
    518                 started_at,
    519                 finished_at,
    520                 RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber,
    521             ),
    522             (
    523                 0,
    524                 101,
    525                 started_at,
    526                 finished_at,
    527                 RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber,
    528             ),
    529             (
    530                 0,
    531                 1,
    532                 finished_at,
    533                 started_at,
    534                 RhiPublicationAttemptEvidenceErrorKind::InvalidTime,
    535             ),
    536         ];
    537         for (ordinal, attempt, start, finish, expected) in cases {
    538             assert_eq!(
    539                 RhiPublicationAttemptEvidence::new(
    540                     &publication(),
    541                     ordinal,
    542                     attempt,
    543                     start,
    544                     finish,
    545                     RhiPublicationAttemptOutcome::Unknown,
    546                 )
    547                 .expect_err("invalid evidence")
    548                 .kind(),
    549                 expected
    550             );
    551         }
    552     }
    553 }