lib

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

authored_draft.rs (29829B)


      1 //! Immutable authored-draft revisions stored before outbound side effects.
      2 
      3 use core::num::NonZeroU64;
      4 use radroots_transport::BoxFuture;
      5 use sha2::{Digest, Sha256};
      6 use std::{string::String, vec::Vec};
      7 
      8 use crate::{
      9     Error,
     10     authored_draft_query::{AuthoredDraftPage, AuthoredDraftQuery, AuthoredDraftScope},
     11     journal::OperationInstanceId,
     12 };
     13 
     14 pub const AUTHORED_DRAFT_PAYLOAD_MAX_BYTES: usize = 4 * 1024 * 1024;
     15 pub const AUTHORED_DRAFT_SCHEMA_MAX_BYTES: usize = 128;
     16 pub const AUTHORED_DRAFT_QUERY_LIMIT_MAX: u16 = 256;
     17 
     18 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     19 #[cfg_attr(feature = "serde", serde(try_from = "[u8; 16]", into = "[u8; 16]"))]
     20 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     21 pub struct AuthoredDraftId([u8; 16]);
     22 
     23 impl AuthoredDraftId {
     24     pub const fn new(value: [u8; 16]) -> Result<Self, Error> {
     25         if bytes_are_zero(&value) {
     26             Err(Error::InvalidAuthoredDraft)
     27         } else {
     28             Ok(Self(value))
     29         }
     30     }
     31 
     32     pub const fn as_bytes(&self) -> &[u8; 16] {
     33         &self.0
     34     }
     35 }
     36 
     37 impl TryFrom<[u8; 16]> for AuthoredDraftId {
     38     type Error = Error;
     39 
     40     fn try_from(value: [u8; 16]) -> Result<Self, Self::Error> {
     41         Self::new(value)
     42     }
     43 }
     44 
     45 impl From<AuthoredDraftId> for [u8; 16] {
     46     fn from(value: AuthoredDraftId) -> Self {
     47         value.0
     48     }
     49 }
     50 
     51 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     52 #[cfg_attr(feature = "serde", serde(try_from = "u64", into = "u64"))]
     53 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     54 pub struct AuthoredDraftRevision(NonZeroU64);
     55 
     56 impl AuthoredDraftRevision {
     57     pub const INITIAL: Self = Self(NonZeroU64::MIN);
     58 
     59     pub const fn new(value: u64) -> Result<Self, Error> {
     60         match NonZeroU64::new(value) {
     61             Some(value) => Ok(Self(value)),
     62             None => Err(Error::InvalidAuthoredDraft),
     63         }
     64     }
     65 
     66     pub const fn get(self) -> u64 {
     67         self.0.get()
     68     }
     69 
     70     pub fn next(self) -> Result<Self, Error> {
     71         self.get()
     72             .checked_add(1)
     73             .ok_or(Error::InvalidAuthoredDraft)
     74             .and_then(Self::new)
     75     }
     76 }
     77 
     78 impl TryFrom<u64> for AuthoredDraftRevision {
     79     type Error = Error;
     80 
     81     fn try_from(value: u64) -> Result<Self, Self::Error> {
     82         Self::new(value)
     83     }
     84 }
     85 
     86 impl From<AuthoredDraftRevision> for u64 {
     87     fn from(value: AuthoredDraftRevision) -> Self {
     88         value.get()
     89     }
     90 }
     91 
     92 /// Product-independent persistence phases for one local authored draft.
     93 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     94 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     95 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     96 pub enum AuthoredDraftStage {
     97     Draft,
     98     MediaPreparing,
     99     MediaUploading,
    100     ReadyToSign,
    101     Queued,
    102     Cancelled,
    103 }
    104 
    105 impl AuthoredDraftStage {
    106     pub const fn is_terminal(self) -> bool {
    107         matches!(self, Self::Cancelled)
    108     }
    109 }
    110 
    111 /// One immutable, integrity-bound draft revision.
    112 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    113 #[cfg_attr(
    114     feature = "serde",
    115     serde(
    116         try_from = "AuthoredDraftRevisionWire",
    117         into = "AuthoredDraftRevisionWire"
    118     )
    119 )]
    120 #[derive(Clone, Debug, Eq, PartialEq)]
    121 pub struct AuthoredDraft {
    122     draft_id: AuthoredDraftId,
    123     revision: AuthoredDraftRevision,
    124     author: [u8; 32],
    125     payload_schema: String,
    126     scope: Option<AuthoredDraftScope>,
    127     payload: Vec<u8>,
    128     payload_sha256: [u8; 32],
    129     stage: AuthoredDraftStage,
    130     operation_id: Option<OperationInstanceId>,
    131     created_at_unix_ms: u64,
    132     updated_at_unix_ms: u64,
    133 }
    134 
    135 #[cfg(feature = "serde")]
    136 #[derive(serde::Serialize, serde::Deserialize)]
    137 struct AuthoredDraftRevisionWire {
    138     draft_id: AuthoredDraftId,
    139     revision: AuthoredDraftRevision,
    140     author: [u8; 32],
    141     payload_schema: String,
    142     #[serde(default, skip_serializing_if = "Option::is_none")]
    143     scope: Option<AuthoredDraftScope>,
    144     payload: Vec<u8>,
    145     payload_sha256: [u8; 32],
    146     stage: AuthoredDraftStage,
    147     operation_id: Option<OperationInstanceId>,
    148     created_at_unix_ms: u64,
    149     updated_at_unix_ms: u64,
    150 }
    151 
    152 #[cfg(feature = "serde")]
    153 impl TryFrom<AuthoredDraftRevisionWire> for AuthoredDraft {
    154     type Error = Error;
    155 
    156     fn try_from(value: AuthoredDraftRevisionWire) -> Result<Self, Self::Error> {
    157         Self::reconstruct_scoped(
    158             value.draft_id,
    159             value.revision,
    160             value.author,
    161             value.payload_schema,
    162             value.scope,
    163             value.payload,
    164             value.payload_sha256,
    165             value.stage,
    166             value.operation_id,
    167             value.created_at_unix_ms,
    168             value.updated_at_unix_ms,
    169         )
    170     }
    171 }
    172 
    173 #[cfg(feature = "serde")]
    174 impl From<AuthoredDraft> for AuthoredDraftRevisionWire {
    175     fn from(value: AuthoredDraft) -> Self {
    176         Self {
    177             draft_id: value.draft_id,
    178             revision: value.revision,
    179             author: value.author,
    180             payload_schema: value.payload_schema,
    181             scope: value.scope,
    182             payload: value.payload,
    183             payload_sha256: value.payload_sha256,
    184             stage: value.stage,
    185             operation_id: value.operation_id,
    186             created_at_unix_ms: value.created_at_unix_ms,
    187             updated_at_unix_ms: value.updated_at_unix_ms,
    188         }
    189     }
    190 }
    191 
    192 impl AuthoredDraft {
    193     #[allow(clippy::too_many_arguments)]
    194     pub fn initial(
    195         draft_id: AuthoredDraftId,
    196         author: [u8; 32],
    197         payload_schema: impl Into<String>,
    198         payload: Vec<u8>,
    199         stage: AuthoredDraftStage,
    200         operation_id: Option<OperationInstanceId>,
    201         created_at_unix_ms: u64,
    202     ) -> Result<Self, Error> {
    203         if matches!(
    204             stage,
    205             AuthoredDraftStage::ReadyToSign
    206                 | AuthoredDraftStage::Queued
    207                 | AuthoredDraftStage::Cancelled
    208         ) {
    209             return Err(Error::InvalidAuthoredDraft);
    210         }
    211         let payload_sha256 = Sha256::digest(payload.as_slice()).into();
    212         Self::reconstruct(
    213             draft_id,
    214             AuthoredDraftRevision::INITIAL,
    215             author,
    216             payload_schema.into(),
    217             payload,
    218             payload_sha256,
    219             stage,
    220             operation_id,
    221             created_at_unix_ms,
    222             created_at_unix_ms,
    223         )
    224     }
    225 
    226     pub fn successor(
    227         &self,
    228         payload: Vec<u8>,
    229         stage: AuthoredDraftStage,
    230         operation_id: Option<OperationInstanceId>,
    231         updated_at_unix_ms: u64,
    232     ) -> Result<Self, Error> {
    233         let payload_sha256 = Sha256::digest(payload.as_slice()).into();
    234         let next = Self::reconstruct_scoped(
    235             self.draft_id,
    236             self.revision.next()?,
    237             self.author,
    238             self.payload_schema.clone(),
    239             self.scope,
    240             payload,
    241             payload_sha256,
    242             stage,
    243             operation_id,
    244             self.created_at_unix_ms,
    245             updated_at_unix_ms,
    246         )?;
    247         next.validate_successor_of(self)?;
    248         Ok(next)
    249     }
    250 
    251     #[allow(clippy::too_many_arguments)]
    252     pub fn reconstruct(
    253         draft_id: AuthoredDraftId,
    254         revision: AuthoredDraftRevision,
    255         author: [u8; 32],
    256         payload_schema: impl Into<String>,
    257         payload: Vec<u8>,
    258         payload_sha256: [u8; 32],
    259         stage: AuthoredDraftStage,
    260         operation_id: Option<OperationInstanceId>,
    261         created_at_unix_ms: u64,
    262         updated_at_unix_ms: u64,
    263     ) -> Result<Self, Error> {
    264         Self::reconstruct_scoped(
    265             draft_id,
    266             revision,
    267             author,
    268             payload_schema,
    269             None,
    270             payload,
    271             payload_sha256,
    272             stage,
    273             operation_id,
    274             created_at_unix_ms,
    275             updated_at_unix_ms,
    276         )
    277     }
    278 
    279     #[allow(clippy::too_many_arguments)]
    280     fn reconstruct_scoped(
    281         draft_id: AuthoredDraftId,
    282         revision: AuthoredDraftRevision,
    283         author: [u8; 32],
    284         payload_schema: impl Into<String>,
    285         scope: Option<AuthoredDraftScope>,
    286         payload: Vec<u8>,
    287         payload_sha256: [u8; 32],
    288         stage: AuthoredDraftStage,
    289         operation_id: Option<OperationInstanceId>,
    290         created_at_unix_ms: u64,
    291         updated_at_unix_ms: u64,
    292     ) -> Result<Self, Error> {
    293         let value = Self {
    294             draft_id,
    295             revision,
    296             author,
    297             payload_schema: payload_schema.into(),
    298             scope,
    299             payload,
    300             payload_sha256,
    301             stage,
    302             operation_id,
    303             created_at_unix_ms,
    304             updated_at_unix_ms,
    305         };
    306         value.validate()?;
    307         Ok(value)
    308     }
    309 
    310     pub fn validate(&self) -> Result<(), Error> {
    311         let schema = self.payload_schema.as_str();
    312         let requires_operation = matches!(
    313             self.stage,
    314             AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
    315         );
    316         if bytes_are_zero(&self.author)
    317             || schema.is_empty()
    318             || schema.len() > AUTHORED_DRAFT_SCHEMA_MAX_BYTES
    319             || schema != schema.trim()
    320             || schema.chars().any(char::is_control)
    321             || self.payload.is_empty()
    322             || self.payload.len() > AUTHORED_DRAFT_PAYLOAD_MAX_BYTES
    323             || Sha256::digest(self.payload.as_slice()).as_slice() != self.payload_sha256
    324             || self.created_at_unix_ms == 0
    325             || self.updated_at_unix_ms < self.created_at_unix_ms
    326             || (requires_operation && self.operation_id.is_none())
    327             || (!requires_operation
    328                 && self.stage != AuthoredDraftStage::Cancelled
    329                 && self.operation_id.is_some())
    330         {
    331             return Err(Error::InvalidAuthoredDraft);
    332         }
    333         Ok(())
    334     }
    335 
    336     pub fn validate_successor_of(&self, previous: &Self) -> Result<(), Error> {
    337         let identity_matches = self.draft_id == previous.draft_id
    338             && self.author == previous.author
    339             && self.payload_schema == previous.payload_schema
    340             && self.scope == previous.scope
    341             && self.created_at_unix_ms == previous.created_at_unix_ms
    342             && self.revision == previous.revision.next()?
    343             && self.updated_at_unix_ms >= previous.updated_at_unix_ms;
    344         let operation_matches = match (previous.operation_id, self.operation_id) {
    345             (Some(previous), Some(next)) => previous == next,
    346             (None, _) => true,
    347             (Some(_), None) => false,
    348         };
    349         let frozen_queue_payload = !matches!(
    350             (previous.stage, self.stage),
    351             (
    352                 AuthoredDraftStage::ReadyToSign,
    353                 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
    354             ) | (AuthoredDraftStage::Queued, AuthoredDraftStage::Queued)
    355         ) || self.payload_sha256 == previous.payload_sha256;
    356         let stage_allowed = match previous.stage {
    357             AuthoredDraftStage::Draft => matches!(
    358                 self.stage,
    359                 AuthoredDraftStage::Draft
    360                     | AuthoredDraftStage::MediaPreparing
    361                     | AuthoredDraftStage::ReadyToSign
    362                     | AuthoredDraftStage::Cancelled
    363             ),
    364             AuthoredDraftStage::MediaPreparing => matches!(
    365                 self.stage,
    366                 AuthoredDraftStage::MediaPreparing
    367                     | AuthoredDraftStage::MediaUploading
    368                     | AuthoredDraftStage::ReadyToSign
    369                     | AuthoredDraftStage::Cancelled
    370             ),
    371             AuthoredDraftStage::MediaUploading => matches!(
    372                 self.stage,
    373                 AuthoredDraftStage::MediaPreparing
    374                     | AuthoredDraftStage::MediaUploading
    375                     | AuthoredDraftStage::ReadyToSign
    376                     | AuthoredDraftStage::Cancelled
    377             ),
    378             AuthoredDraftStage::ReadyToSign => matches!(
    379                 self.stage,
    380                 AuthoredDraftStage::ReadyToSign
    381                     | AuthoredDraftStage::Queued
    382                     | AuthoredDraftStage::Cancelled
    383             ),
    384             AuthoredDraftStage::Queued => matches!(
    385                 self.stage,
    386                 AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled
    387             ),
    388             AuthoredDraftStage::Cancelled => false,
    389         };
    390         if !identity_matches || !operation_matches || !frozen_queue_payload || !stage_allowed {
    391             return Err(Error::DraftRevisionConflict);
    392         }
    393         Ok(())
    394     }
    395 
    396     pub const fn draft_id(&self) -> AuthoredDraftId {
    397         self.draft_id
    398     }
    399     pub const fn revision(&self) -> AuthoredDraftRevision {
    400         self.revision
    401     }
    402     pub const fn author(&self) -> &[u8; 32] {
    403         &self.author
    404     }
    405     pub fn payload_schema(&self) -> &str {
    406         self.payload_schema.as_str()
    407     }
    408     /// Selects an immutable scope while constructing an initial local record.
    409     pub fn with_scope(mut self, scope: AuthoredDraftScope) -> Result<Self, Error> {
    410         if self.revision != AuthoredDraftRevision::INITIAL || self.scope.is_some() {
    411             return Err(Error::InvalidAuthoredDraft);
    412         }
    413         self.scope = Some(scope);
    414         Ok(self)
    415     }
    416     pub const fn scope(&self) -> Option<AuthoredDraftScope> {
    417         self.scope
    418     }
    419 
    420     pub fn payload(&self) -> &[u8] {
    421         self.payload.as_slice()
    422     }
    423     pub const fn payload_sha256(&self) -> &[u8; 32] {
    424         &self.payload_sha256
    425     }
    426     pub const fn stage(&self) -> AuthoredDraftStage {
    427         self.stage
    428     }
    429     pub const fn operation_id(&self) -> Option<OperationInstanceId> {
    430         self.operation_id
    431     }
    432     pub const fn created_at_unix_ms(&self) -> u64 {
    433         self.created_at_unix_ms
    434     }
    435     pub const fn updated_at_unix_ms(&self) -> u64 {
    436         self.updated_at_unix_ms
    437     }
    438 }
    439 
    440 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    441 pub enum DraftAppendDisposition {
    442     Inserted,
    443     Replay,
    444 }
    445 
    446 #[derive(Clone, Debug, Eq, PartialEq)]
    447 pub struct DraftAppendReceipt {
    448     draft: AuthoredDraft,
    449     disposition: DraftAppendDisposition,
    450 }
    451 
    452 impl DraftAppendReceipt {
    453     pub const fn new(draft: AuthoredDraft, disposition: DraftAppendDisposition) -> Self {
    454         Self { draft, disposition }
    455     }
    456     pub const fn draft(&self) -> &AuthoredDraft {
    457         &self.draft
    458     }
    459     pub const fn disposition(&self) -> DraftAppendDisposition {
    460         self.disposition
    461     }
    462 }
    463 
    464 pub trait AuthoredDraftStore: Send + Sync {
    465     /// Atomically appends two opaque revisions or replays both exact snapshots.
    466     ///
    467     /// Unsupported backends fail closed; sequential single-row calls are not an
    468     /// implementation. Caller cancellation after commit may lose the receipt
    469     /// without rolling back either row. Replay is historical, not a head lease.
    470     fn append_authored_draft_pair(
    471         &self,
    472         _pair: crate::authored_draft_pair::AuthoredDraftPair,
    473     ) -> BoxFuture<'_, Result<[DraftAppendReceipt; 2], Error>> {
    474         Box::pin(async { Err(Error::BackendUnavailable) })
    475     }
    476 
    477     fn query_authored_drafts(
    478         &self,
    479         query: AuthoredDraftQuery,
    480     ) -> BoxFuture<'_, Result<AuthoredDraftPage, Error>>;
    481 
    482     fn append_authored_draft(
    483         &self,
    484         draft: AuthoredDraft,
    485         expected_head: Option<AuthoredDraftRevision>,
    486     ) -> BoxFuture<'_, Result<DraftAppendReceipt, Error>>;
    487 
    488     fn authored_draft_head(
    489         &self,
    490         draft_id: AuthoredDraftId,
    491     ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>>;
    492 
    493     fn authored_draft_revision(
    494         &self,
    495         draft_id: AuthoredDraftId,
    496         revision: AuthoredDraftRevision,
    497     ) -> BoxFuture<'_, Result<Option<AuthoredDraft>, Error>>;
    498 
    499     fn authored_draft_heads(
    500         &self,
    501         author: [u8; 32],
    502         limit: u16,
    503     ) -> BoxFuture<'_, Result<Vec<AuthoredDraft>, Error>>;
    504 }
    505 
    506 const fn bytes_are_zero<const N: usize>(bytes: &[u8; N]) -> bool {
    507     let mut index = 0;
    508     while index < bytes.len() {
    509         if bytes[index] != 0 {
    510             return false;
    511         }
    512         index += 1;
    513     }
    514     true
    515 }
    516 
    517 #[cfg(test)]
    518 mod tests {
    519     use super::*;
    520 
    521     fn draft() -> AuthoredDraft {
    522         AuthoredDraft::initial(
    523             AuthoredDraftId::new([1; 16]).unwrap(),
    524             [2; 32],
    525             "radroots.phase1-draft.v1",
    526             b"draft".to_vec(),
    527             AuthoredDraftStage::Draft,
    528             None,
    529             10,
    530         )
    531         .unwrap()
    532     }
    533 
    534     #[test]
    535     fn revisions_are_immutable_and_phase_ordered() {
    536         let first = draft();
    537         let media = first
    538             .successor(
    539                 b"media".to_vec(),
    540                 AuthoredDraftStage::MediaPreparing,
    541                 None,
    542                 11,
    543             )
    544             .unwrap();
    545         let operation = OperationInstanceId::new([3; 16]).unwrap();
    546         let ready = media
    547             .successor(
    548                 b"ready".to_vec(),
    549                 AuthoredDraftStage::ReadyToSign,
    550                 Some(operation),
    551                 12,
    552             )
    553             .unwrap();
    554         assert!(
    555             ready
    556                 .successor(
    557                     b"changed".to_vec(),
    558                     AuthoredDraftStage::Queued,
    559                     Some(operation),
    560                     13,
    561                 )
    562                 .is_err()
    563         );
    564         let queued = ready
    565             .successor(
    566                 b"ready".to_vec(),
    567                 AuthoredDraftStage::Queued,
    568                 Some(operation),
    569                 13,
    570             )
    571             .unwrap();
    572         assert_eq!(queued.revision().get(), 4);
    573         assert!(
    574             queued
    575                 .successor(b"x".to_vec(), AuthoredDraftStage::Draft, None, 14)
    576                 .is_err()
    577         );
    578     }
    579 
    580     #[test]
    581     fn reconstruction_rejects_tampering_and_invalid_contracts() {
    582         let value = draft();
    583         assert!(AuthoredDraftId::new([0; 16]).is_err());
    584         assert!(AuthoredDraftRevision::new(0).is_err());
    585         assert!(
    586             AuthoredDraft::reconstruct(
    587                 value.draft_id(),
    588                 value.revision(),
    589                 *value.author(),
    590                 value.payload_schema(),
    591                 value.payload().to_vec(),
    592                 [9; 32],
    593                 value.stage(),
    594                 None,
    595                 value.created_at_unix_ms(),
    596                 value.updated_at_unix_ms(),
    597             )
    598             .is_err()
    599         );
    600         assert!(
    601             AuthoredDraft::initial(
    602                 value.draft_id(),
    603                 [0; 32],
    604                 "schema",
    605                 Vec::new(),
    606                 AuthoredDraftStage::ReadyToSign,
    607                 None,
    608                 0,
    609             )
    610             .is_err()
    611         );
    612     }
    613 
    614     #[test]
    615     fn validation_rejects_every_independent_invalid_field() {
    616         let value = draft();
    617         let digest = |payload: &[u8]| -> [u8; 32] { Sha256::digest(payload).into() };
    618         let reconstruct = |author: [u8; 32],
    619                            schema: String,
    620                            payload: Vec<u8>,
    621                            payload_sha256: [u8; 32],
    622                            stage: AuthoredDraftStage,
    623                            operation_id: Option<OperationInstanceId>,
    624                            created_at_unix_ms: u64,
    625                            updated_at_unix_ms: u64| {
    626             AuthoredDraft::reconstruct(
    627                 value.draft_id(),
    628                 value.revision(),
    629                 author,
    630                 schema,
    631                 payload,
    632                 payload_sha256,
    633                 stage,
    634                 operation_id,
    635                 created_at_unix_ms,
    636                 updated_at_unix_ms,
    637             )
    638         };
    639 
    640         let operation = OperationInstanceId::new([3; 16]).unwrap();
    641         let valid_payload = b"draft".to_vec();
    642         let valid_digest = digest(&valid_payload);
    643         let invalid = [
    644             reconstruct(
    645                 [0; 32],
    646                 "schema".into(),
    647                 valid_payload.clone(),
    648                 valid_digest,
    649                 AuthoredDraftStage::Draft,
    650                 None,
    651                 10,
    652                 10,
    653             ),
    654             reconstruct(
    655                 [2; 32],
    656                 String::new(),
    657                 valid_payload.clone(),
    658                 valid_digest,
    659                 AuthoredDraftStage::Draft,
    660                 None,
    661                 10,
    662                 10,
    663             ),
    664             reconstruct(
    665                 [2; 32],
    666                 "x".repeat(AUTHORED_DRAFT_SCHEMA_MAX_BYTES + 1),
    667                 valid_payload.clone(),
    668                 valid_digest,
    669                 AuthoredDraftStage::Draft,
    670                 None,
    671                 10,
    672                 10,
    673             ),
    674             reconstruct(
    675                 [2; 32],
    676                 " schema".into(),
    677                 valid_payload.clone(),
    678                 valid_digest,
    679                 AuthoredDraftStage::Draft,
    680                 None,
    681                 10,
    682                 10,
    683             ),
    684             reconstruct(
    685                 [2; 32],
    686                 "bad\nschema".into(),
    687                 valid_payload.clone(),
    688                 valid_digest,
    689                 AuthoredDraftStage::Draft,
    690                 None,
    691                 10,
    692                 10,
    693             ),
    694             reconstruct(
    695                 [2; 32],
    696                 "schema".into(),
    697                 Vec::new(),
    698                 digest(&[]),
    699                 AuthoredDraftStage::Draft,
    700                 None,
    701                 10,
    702                 10,
    703             ),
    704             reconstruct(
    705                 [2; 32],
    706                 "schema".into(),
    707                 valid_payload.clone(),
    708                 [9; 32],
    709                 AuthoredDraftStage::Draft,
    710                 None,
    711                 10,
    712                 10,
    713             ),
    714             reconstruct(
    715                 [2; 32],
    716                 "schema".into(),
    717                 valid_payload.clone(),
    718                 valid_digest,
    719                 AuthoredDraftStage::Draft,
    720                 None,
    721                 0,
    722                 10,
    723             ),
    724             reconstruct(
    725                 [2; 32],
    726                 "schema".into(),
    727                 valid_payload.clone(),
    728                 valid_digest,
    729                 AuthoredDraftStage::Draft,
    730                 None,
    731                 10,
    732                 9,
    733             ),
    734             reconstruct(
    735                 [2; 32],
    736                 "schema".into(),
    737                 valid_payload.clone(),
    738                 valid_digest,
    739                 AuthoredDraftStage::ReadyToSign,
    740                 None,
    741                 10,
    742                 10,
    743             ),
    744             reconstruct(
    745                 [2; 32],
    746                 "schema".into(),
    747                 valid_payload,
    748                 valid_digest,
    749                 AuthoredDraftStage::Draft,
    750                 Some(operation),
    751                 10,
    752                 10,
    753             ),
    754         ];
    755         assert!(invalid.into_iter().all(|result| result.is_err()));
    756 
    757         let oversized = vec![0; AUTHORED_DRAFT_PAYLOAD_MAX_BYTES + 1];
    758         assert!(
    759             reconstruct(
    760                 [2; 32],
    761                 "schema".into(),
    762                 oversized.clone(),
    763                 digest(&oversized),
    764                 AuthoredDraftStage::Draft,
    765                 None,
    766                 10,
    767                 10,
    768             )
    769             .is_err()
    770         );
    771         assert!(
    772             AuthoredDraftRevision::new(u64::MAX)
    773                 .unwrap()
    774                 .next()
    775                 .is_err()
    776         );
    777         assert!(AuthoredDraftStage::Cancelled.is_terminal());
    778         assert!(!AuthoredDraftStage::Draft.is_terminal());
    779         for forbidden_initial_stage in [
    780             AuthoredDraftStage::ReadyToSign,
    781             AuthoredDraftStage::Queued,
    782             AuthoredDraftStage::Cancelled,
    783         ] {
    784             assert!(
    785                 AuthoredDraft::initial(
    786                     AuthoredDraftId::new([4; 16]).unwrap(),
    787                     [5; 32],
    788                     "schema",
    789                     b"payload".to_vec(),
    790                     forbidden_initial_stage,
    791                     None,
    792                     10,
    793                 )
    794                 .is_err()
    795             );
    796         }
    797     }
    798 
    799     #[test]
    800     fn every_stage_pair_obeys_the_transition_matrix() {
    801         let stages = [
    802             AuthoredDraftStage::Draft,
    803             AuthoredDraftStage::MediaPreparing,
    804             AuthoredDraftStage::MediaUploading,
    805             AuthoredDraftStage::ReadyToSign,
    806             AuthoredDraftStage::Queued,
    807             AuthoredDraftStage::Cancelled,
    808         ];
    809         let operation = OperationInstanceId::new([3; 16]).unwrap();
    810 
    811         for previous_stage in stages {
    812             for next_stage in stages {
    813                 let previous_operation = matches!(
    814                     previous_stage,
    815                     AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
    816                 )
    817                 .then_some(operation);
    818                 let previous = AuthoredDraft::reconstruct(
    819                     AuthoredDraftId::new([1; 16]).unwrap(),
    820                     AuthoredDraftRevision::INITIAL,
    821                     [2; 32],
    822                     "schema",
    823                     b"payload".to_vec(),
    824                     Sha256::digest(b"payload").into(),
    825                     previous_stage,
    826                     previous_operation,
    827                     10,
    828                     10,
    829                 )
    830                 .unwrap();
    831                 let next_operation = match next_stage {
    832                     AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued => Some(operation),
    833                     AuthoredDraftStage::Cancelled => previous_operation,
    834                     _ => None,
    835                 };
    836                 let payload = if matches!(
    837                     (previous_stage, next_stage),
    838                     (
    839                         AuthoredDraftStage::ReadyToSign,
    840                         AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
    841                     ) | (AuthoredDraftStage::Queued, AuthoredDraftStage::Queued)
    842                 ) {
    843                     b"payload".to_vec()
    844                 } else {
    845                     b"next".to_vec()
    846                 };
    847                 let allowed = match previous_stage {
    848                     AuthoredDraftStage::Draft => matches!(
    849                         next_stage,
    850                         AuthoredDraftStage::Draft
    851                             | AuthoredDraftStage::MediaPreparing
    852                             | AuthoredDraftStage::ReadyToSign
    853                             | AuthoredDraftStage::Cancelled
    854                     ),
    855                     AuthoredDraftStage::MediaPreparing => matches!(
    856                         next_stage,
    857                         AuthoredDraftStage::MediaPreparing
    858                             | AuthoredDraftStage::MediaUploading
    859                             | AuthoredDraftStage::ReadyToSign
    860                             | AuthoredDraftStage::Cancelled
    861                     ),
    862                     AuthoredDraftStage::MediaUploading => matches!(
    863                         next_stage,
    864                         AuthoredDraftStage::MediaPreparing
    865                             | AuthoredDraftStage::MediaUploading
    866                             | AuthoredDraftStage::ReadyToSign
    867                             | AuthoredDraftStage::Cancelled
    868                     ),
    869                     AuthoredDraftStage::ReadyToSign => matches!(
    870                         next_stage,
    871                         AuthoredDraftStage::ReadyToSign
    872                             | AuthoredDraftStage::Queued
    873                             | AuthoredDraftStage::Cancelled
    874                     ),
    875                     AuthoredDraftStage::Queued => matches!(
    876                         next_stage,
    877                         AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled
    878                     ),
    879                     AuthoredDraftStage::Cancelled => false,
    880                 };
    881                 assert_eq!(
    882                     previous
    883                         .successor(payload, next_stage, next_operation, 11)
    884                         .is_ok(),
    885                     allowed,
    886                     "{previous_stage:?} -> {next_stage:?}"
    887                 );
    888             }
    889         }
    890 
    891         let previous = AuthoredDraft::reconstruct(
    892             AuthoredDraftId::new([1; 16]).unwrap(),
    893             AuthoredDraftRevision::INITIAL,
    894             [2; 32],
    895             "schema",
    896             b"payload".to_vec(),
    897             Sha256::digest(b"payload").into(),
    898             AuthoredDraftStage::ReadyToSign,
    899             Some(operation),
    900             10,
    901             10,
    902         )
    903         .unwrap();
    904         assert!(
    905             previous
    906                 .successor(
    907                     b"payload".to_vec(),
    908                     AuthoredDraftStage::ReadyToSign,
    909                     Some(OperationInstanceId::new([4; 16]).unwrap()),
    910                     11,
    911                 )
    912                 .is_err()
    913         );
    914         assert!(
    915             previous
    916                 .successor(b"payload".to_vec(), AuthoredDraftStage::Cancelled, None, 11,)
    917                 .is_err()
    918         );
    919         let rebound_identity = AuthoredDraft::reconstruct(
    920             AuthoredDraftId::new([9; 16]).unwrap(),
    921             previous.revision().next().unwrap(),
    922             *previous.author(),
    923             previous.payload_schema(),
    924             previous.payload().to_vec(),
    925             *previous.payload_sha256(),
    926             AuthoredDraftStage::ReadyToSign,
    927             previous.operation_id(),
    928             previous.created_at_unix_ms(),
    929             11,
    930         )
    931         .unwrap();
    932         assert_eq!(
    933             rebound_identity.validate_successor_of(&previous),
    934             Err(Error::DraftRevisionConflict)
    935         );
    936     }
    937 }