authored_draft_submission.rs (11678B)
1 //! Atomic association of an immutable captured draft with an authored preparation. 2 //! 3 //! The application owns the complete semantic request in the intent payload. 4 //! Storage compares that payload and every preparation field on replay. A command 5 //! key is scoped by the stable author, never by a process generation or a digest. 6 use crate::{ 7 Error, 8 atomic::AtomicCommitId, 9 authored::{ArtifactOrigin, SigningState}, 10 authored_atomic::PrepareAuthoredOperation, 11 authored_draft::{AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, AuthoredDraftStage}, 12 authored_draft_query::{AuthoredDraftQuery, AuthoredDraftScope}, 13 }; 14 use sha2::{Digest, Sha256}; 15 16 /// Captured source metadata; contains no application payload or credentials. 17 #[derive(Clone, Debug, Eq, PartialEq)] 18 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 19 #[cfg_attr(feature = "serde", serde(try_from = "SourceWire", into = "SourceWire"))] 20 pub struct AuthoredDraftSource { 21 draft_id: AuthoredDraftId, 22 revision: AuthoredDraftRevision, 23 author: [u8; 32], 24 payload_schema: String, 25 scope: Option<AuthoredDraftScope>, 26 payload_sha256: [u8; 32], 27 stage: AuthoredDraftStage, 28 created_at_unix_ms: u64, 29 updated_at_unix_ms: u64, 30 } 31 impl AuthoredDraftSource { 32 pub fn capture(draft: &AuthoredDraft) -> Result<Self, Error> { 33 draft.validate()?; 34 let value = Self { 35 draft_id: draft.draft_id(), 36 revision: draft.revision(), 37 author: *draft.author(), 38 payload_schema: draft.payload_schema().to_owned(), 39 scope: draft.scope(), 40 payload_sha256: *draft.payload_sha256(), 41 stage: draft.stage(), 42 created_at_unix_ms: draft.created_at_unix_ms(), 43 updated_at_unix_ms: draft.updated_at_unix_ms(), 44 }; 45 value.validate()?; 46 Ok(value) 47 } 48 fn validate(&self) -> Result<(), Error> { 49 AuthoredDraftQuery::new(self.author, &self.payload_schema, self.scope, 1)?; 50 if !matches!( 51 self.stage, 52 AuthoredDraftStage::Draft 53 | AuthoredDraftStage::MediaPreparing 54 | AuthoredDraftStage::MediaUploading 55 ) || self.created_at_unix_ms == 0 56 || self.updated_at_unix_ms < self.created_at_unix_ms 57 { 58 return Err(Error::InvalidAuthoredDraft); 59 } 60 Ok(()) 61 } 62 pub fn matches(&self, draft: &AuthoredDraft) -> bool { 63 Self::capture(draft).is_ok_and(|captured| captured == *self) 64 } 65 pub const fn draft_id(&self) -> AuthoredDraftId { 66 self.draft_id 67 } 68 pub const fn revision(&self) -> AuthoredDraftRevision { 69 self.revision 70 } 71 pub const fn author(&self) -> &[u8; 32] { 72 &self.author 73 } 74 pub fn payload_schema(&self) -> &str { 75 &self.payload_schema 76 } 77 pub const fn scope(&self) -> Option<AuthoredDraftScope> { 78 self.scope 79 } 80 pub const fn payload_sha256(&self) -> &[u8; 32] { 81 &self.payload_sha256 82 } 83 } 84 85 /// A captured request and its distinct immutable intent, installed without effects. 86 #[derive(Clone, Eq, PartialEq)] 87 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 88 #[cfg_attr( 89 feature = "serde", 90 serde(try_from = "SubmissionWire", into = "SubmissionWire") 91 )] 92 pub struct PrepareFromDraft { 93 command_id: AtomicCommitId, 94 source: AuthoredDraftSource, 95 intent: AuthoredDraft, 96 preparation: PrepareAuthoredOperation, 97 } 98 impl PrepareFromDraft { 99 /// Captures an intent whose prerequisites are complete and operation is associated. 100 pub fn new( 101 command_id: AtomicCommitId, 102 source: AuthoredDraftSource, 103 intent: AuthoredDraft, 104 preparation: PrepareAuthoredOperation, 105 ) -> Result<Self, Error> { 106 if !matches!( 107 intent.stage(), 108 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued 109 ) { 110 return Err(Error::AtomicWorkflowMismatch); 111 } 112 Self::from_parts(command_id, source, intent, preparation) 113 } 114 115 /// Captures an operation before caller-owned prerequisites are complete. 116 /// 117 /// The waiting draft has no operation association or signing authority. Its 118 /// original complete snapshot and prepared operation are bound immutably in 119 /// the composite receipt. The caller owns prerequisite progression and must 120 /// later associate that same operation without changing its captured plan. 121 pub fn new_waiting( 122 command_id: AtomicCommitId, 123 source: AuthoredDraftSource, 124 intent: AuthoredDraft, 125 preparation: PrepareAuthoredOperation, 126 ) -> Result<Self, Error> { 127 if !matches!( 128 intent.stage(), 129 AuthoredDraftStage::Draft 130 | AuthoredDraftStage::MediaPreparing 131 | AuthoredDraftStage::MediaUploading 132 ) { 133 return Err(Error::AtomicWorkflowMismatch); 134 } 135 Self::from_parts(command_id, source, intent, preparation) 136 } 137 138 fn from_parts( 139 command_id: AtomicCommitId, 140 source: AuthoredDraftSource, 141 intent: AuthoredDraft, 142 preparation: PrepareAuthoredOperation, 143 ) -> Result<Self, Error> { 144 let value = Self { 145 command_id, 146 source, 147 intent, 148 preparation, 149 }; 150 value.validate()?; 151 Ok(value) 152 } 153 pub fn validate(&self) -> Result<(), Error> { 154 AtomicCommitId::new(*self.command_id.as_bytes())?; 155 self.source.validate()?; 156 self.intent.validate()?; 157 let operation = self.preparation.operation(); 158 let at = self.preparation.requested_at_unix_ms(); 159 let operation_matches = match self.intent.stage() { 160 AuthoredDraftStage::Draft 161 | AuthoredDraftStage::MediaPreparing 162 | AuthoredDraftStage::MediaUploading => self.intent.operation_id().is_none(), 163 AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued => { 164 self.intent.operation_id() == Some(operation.operation_id()) 165 } 166 AuthoredDraftStage::Cancelled => false, 167 }; 168 if self.intent.draft_id() == self.source.draft_id 169 || self.intent.author() != &self.source.author 170 || self.intent.scope() != self.source.scope 171 || self.intent.revision() != AuthoredDraftRevision::INITIAL 172 || !operation_matches 173 || self.intent.created_at_unix_ms() != at 174 || self.intent.updated_at_unix_ms() != at 175 || at < self.source.updated_at_unix_ms 176 || operation.created_at_unix_ms() != at 177 || operation.updated_at_unix_ms() != at 178 || operation.revision().get() != 1 179 { 180 return Err(Error::AtomicWorkflowMismatch); 181 } 182 for artifact in self.preparation.artifacts() { 183 if artifact.origin() != ArtifactOrigin::Planned 184 || artifact.signing_state() != SigningState::Planned 185 || artifact.revision().get() != 1 186 || artifact.created_at_unix_ms() != at 187 || artifact.updated_at_unix_ms() != at 188 || artifact 189 .plan() 190 .ok_or(Error::AtomicWorkflowMismatch)? 191 .decode()? 192 .plan() 193 .author() 194 .as_bytes() 195 != &self.source.author 196 { 197 return Err(Error::AtomicWorkflowMismatch); 198 } 199 } 200 if self.preparation.delivery_plans().iter().any(|plan| { 201 plan.revision().get() != 1 202 || plan.created_at_unix_ms() != at 203 || plan.updated_at_unix_ms() != at 204 }) { 205 return Err(Error::AtomicWorkflowMismatch); 206 } 207 Ok(()) 208 } 209 /// Stable lookup key, available before persistence and after restart. 210 pub fn commit_id_for(author: &[u8; 32], command_id: AtomicCommitId) -> AtomicCommitId { 211 let mut hash = Sha256::new(); 212 hash.update(b"radroots.authored.draft.submission.id.v1\0"); 213 hash.update(author); 214 hash.update(command_id.as_bytes()); 215 let digest = hash.finalize(); 216 let mut id = [0; 16]; 217 id.copy_from_slice(&digest[..16]); 218 AtomicCommitId::new(id).expect("SHA-256 derived submission identity is nonzero") 219 } 220 pub fn commit_id(&self) -> AtomicCommitId { 221 Self::commit_id_for(&self.source.author, self.command_id) 222 } 223 pub const fn command_id(&self) -> AtomicCommitId { 224 self.command_id 225 } 226 pub const fn source(&self) -> &AuthoredDraftSource { 227 &self.source 228 } 229 pub const fn intent(&self) -> &AuthoredDraft { 230 &self.intent 231 } 232 pub const fn preparation(&self) -> &PrepareAuthoredOperation { 233 &self.preparation 234 } 235 } 236 impl core::fmt::Debug for PrepareFromDraft { 237 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 238 formatter 239 .debug_struct("PrepareFromDraft") 240 .field("command_id", &self.command_id) 241 .field("source", &self.source) 242 .field("intent_id", &self.intent.draft_id()) 243 .field("operation_id", &self.preparation.operation().operation_id()) 244 .finish_non_exhaustive() 245 } 246 } 247 248 #[cfg(feature = "serde")] 249 #[derive(serde::Serialize, serde::Deserialize)] 250 #[serde(deny_unknown_fields)] 251 struct SourceWire { 252 draft_id: AuthoredDraftId, 253 revision: AuthoredDraftRevision, 254 author: [u8; 32], 255 payload_schema: String, 256 scope: Option<AuthoredDraftScope>, 257 payload_sha256: [u8; 32], 258 stage: AuthoredDraftStage, 259 created_at_unix_ms: u64, 260 updated_at_unix_ms: u64, 261 } 262 #[cfg(feature = "serde")] 263 impl TryFrom<SourceWire> for AuthoredDraftSource { 264 type Error = Error; 265 fn try_from(v: SourceWire) -> Result<Self, Error> { 266 let value = Self { 267 draft_id: v.draft_id, 268 revision: v.revision, 269 author: v.author, 270 payload_schema: v.payload_schema, 271 scope: v.scope, 272 payload_sha256: v.payload_sha256, 273 stage: v.stage, 274 created_at_unix_ms: v.created_at_unix_ms, 275 updated_at_unix_ms: v.updated_at_unix_ms, 276 }; 277 value.validate()?; 278 Ok(value) 279 } 280 } 281 #[cfg(feature = "serde")] 282 impl From<AuthoredDraftSource> for SourceWire { 283 fn from(v: AuthoredDraftSource) -> Self { 284 Self { 285 draft_id: v.draft_id, 286 revision: v.revision, 287 author: v.author, 288 payload_schema: v.payload_schema, 289 scope: v.scope, 290 payload_sha256: v.payload_sha256, 291 stage: v.stage, 292 created_at_unix_ms: v.created_at_unix_ms, 293 updated_at_unix_ms: v.updated_at_unix_ms, 294 } 295 } 296 } 297 #[cfg(feature = "serde")] 298 #[derive(serde::Serialize, serde::Deserialize)] 299 #[serde(deny_unknown_fields)] 300 struct SubmissionWire { 301 command_id: AtomicCommitId, 302 source: AuthoredDraftSource, 303 intent: AuthoredDraft, 304 preparation: PrepareAuthoredOperation, 305 } 306 #[cfg(feature = "serde")] 307 impl TryFrom<SubmissionWire> for PrepareFromDraft { 308 type Error = Error; 309 fn try_from(v: SubmissionWire) -> Result<Self, Error> { 310 Self::from_parts(v.command_id, v.source, v.intent, v.preparation) 311 } 312 } 313 #[cfg(feature = "serde")] 314 impl From<PrepareFromDraft> for SubmissionWire { 315 fn from(v: PrepareFromDraft) -> Self { 316 Self { 317 command_id: v.command_id, 318 source: v.source, 319 intent: v.intent, 320 preparation: v.preparation, 321 } 322 } 323 }