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 }