1use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
4
5use super::*;
6use crate::admission_operation::{
7 AdmissionAttachment, AdmissionAttachmentKind, AdmissionOperationState,
8};
9use chio_core_types::economic_continuity::{
10 EconomicAdmissionHandoffStateV1, EconomicEffectSlotV1, EconomicEffectStateV1,
11 EconomicEffectTerminalV1, EconomicNoEffectKindV1, EconomicResourceHeadV1, EconomicStateBatchV1,
12 VerifiedEconomicEffectCancellationAdvance,
13};
14use chio_core_types::provider_attempt::{
15 ProviderAttemptCheckpointV1, ProviderAttemptPhaseV1, ProviderCancellationBindingV1,
16};
17
18mod persistence;
19pub use persistence::*;
20mod terminal;
21pub use terminal::*;
22mod transport;
23
24#[allow(dead_code)]
25fn release_id(
26 field: &'static str,
27 value: impl Into<String>,
28) -> Result<AdmissionIdentifier, ToolOutcomeError> {
29 AdmissionIdentifier::try_new(field, value)
30 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))
31}
32
33#[allow(dead_code)]
34fn imported_digest(
35 field: &'static str,
36 value: impl Into<String>,
37) -> Result<AdmissionDigest, ToolOutcomeError> {
38 AdmissionDigest::try_new(field, value)
39 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ReleaseEvidenceArtifactKindV1 {
45 ParticipantQuerySnapshot,
46 SignedTransportStatus,
47 EconomicEffectCancellation,
48 MonotonicAttemptCheckpoint,
49 TerminalToolOutcome,
50 TerminalPostReturnEvaluation,
51 VerifierPolicy,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct ImmutableReleaseArtifactV1 {
57 pub(super) kind: ReleaseEvidenceArtifactKindV1,
58 pub(super) evidence_id: AdmissionIdentifier,
59 pub(super) digest: AdmissionDigest,
60 pub(super) value: Value,
61}
62
63impl ImmutableReleaseArtifactV1 {
64 #[allow(dead_code)]
65 fn new(
66 kind: ReleaseEvidenceArtifactKindV1,
67 evidence_id: AdmissionIdentifier,
68 value: Value,
69 ) -> Result<Self, ToolOutcomeError> {
70 let bytes = bounded(
71 "release_artifact.value",
72 &value,
73 MAX_EVIDENCE_ARTIFACT_BYTES,
74 )?;
75 Ok(Self {
76 kind,
77 evidence_id,
78 digest: digest_bytes("release_artifact.digest", &bytes)?,
79 value,
80 })
81 }
82
83 fn validate(&self) -> Result<(), ToolOutcomeError> {
84 let digest = digest_bytes(
85 "release_artifact.digest",
86 &bounded(
87 "release_artifact.value",
88 &self.value,
89 MAX_EVIDENCE_ARTIFACT_BYTES,
90 )?,
91 )?;
92 if digest != self.digest {
93 return Err(ToolOutcomeError::Binding("release_artifact.digest"));
94 }
95 Ok(())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct VerifierPolicyArtifactV1 {
102 policy: Value,
103}
104
105fn parse_artifact_value<T>(artifact: &ImmutableReleaseArtifactV1) -> Result<T, ToolOutcomeError>
106where
107 T: serde::de::DeserializeOwned + Serialize,
108{
109 let parsed = serde_json::from_value::<T>(artifact.value.clone())
110 .map_err(|_| ToolOutcomeError::Invalid("release_artifact.value"))?;
111 let encoded = serde_json::to_value(&parsed)
112 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
113 if encoded != artifact.value {
114 return Err(ToolOutcomeError::Invalid(
115 "release_artifact.noncanonical_value",
116 ));
117 }
118 Ok(parsed)
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123enum ReleaseParticipantV1 {
124 Broker,
125 Budget,
126 Approval,
127 Nonce,
128 OutcomeEligibility,
129 Payment,
130 CreditExposure,
131 Channel,
132 Transport,
133}
134
135impl ReleaseParticipantV1 {
136 const fn as_str(self) -> &'static str {
137 match self {
138 Self::Broker => "broker",
139 Self::Budget => "budget",
140 Self::Approval => "approval",
141 Self::Nonce => "nonce",
142 Self::OutcomeEligibility => "outcome-eligibility",
143 Self::Payment => "payment",
144 Self::CreditExposure => "credit-exposure",
145 Self::Channel => "channel",
146 Self::Transport => "transport",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153enum ParticipantNoEffectDispositionV1 {
154 NeverAcquired,
155 ReleasedBeforeDispatch,
156 NotDispatched,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct ParticipantQueryRecordV1 {
162 source_record_id: AdmissionIdentifier,
163 source_record_digest: AdmissionDigest,
164 source_record_version: u64,
165 source_store_fence: StoreMutationFence,
166 source_commit_index: u64,
167 observed_at_unix_ms: u64,
168 source_attachments: Vec<AdmissionAttachment>,
169}
170
171impl ParticipantQueryRecordV1 {
172 fn from_qualified_operation(
173 operation: &AdmissionOperationV1,
174 participant: ReleaseParticipantV1,
175 context: &AdmissionProjectionContext,
176 ) -> Result<Self, ToolOutcomeError> {
177 #[derive(Serialize)]
178 struct SourceDigest<'a> {
179 operation: &'a AdmissionOperationV1,
180 participant: ReleaseParticipantV1,
181 store_fence: &'a StoreMutationFence,
182 }
183
184 Ok(Self {
185 source_record_id: release_id(
186 "participant_query_source_record_id",
187 format!(
188 "{}:{}:{}",
189 operation.binding().operation_id().as_str(),
190 participant.as_str(),
191 operation.version()
192 ),
193 )?,
194 source_record_digest: domain_digest(
195 "chio.qualified-predispatch-participant-query.v1",
196 &SourceDigest {
197 operation,
198 participant,
199 store_fence: &context.store_fence,
200 },
201 )?,
202 source_record_version: operation.version(),
203 source_store_fence: context.store_fence.clone(),
204 source_commit_index: operation.version(),
205 observed_at_unix_ms: context.trusted_time_unix_ms,
206 source_attachments: Vec::new(),
207 })
208 }
209
210 fn from_qualified_release(
211 operation: &AdmissionOperationV1,
212 participant: ReleaseParticipantV1,
213 context: &AdmissionProjectionContext,
214 ) -> Result<Self, ToolOutcomeError> {
215 let mut record = Self::from_qualified_operation(operation, participant, context)?;
216 record.source_attachments = participant_attachments(operation, participant);
217 Ok(record)
218 }
219
220 #[cfg(test)]
221 fn for_test(
222 source_record_id: AdmissionIdentifier,
223 source_record_digest: AdmissionDigest,
224 source_attachments: Vec<AdmissionAttachment>,
225 observed_at_unix_ms: u64,
226 ) -> Self {
227 Self {
228 source_record_id,
229 source_record_digest,
230 source_record_version: 1,
231 source_store_fence: StoreMutationFence {
232 store_uuid: "store-1".to_owned(),
233 lease_id: "store-lease-1".to_owned(),
234 owner_epoch: 9,
235 },
236 source_commit_index: 1,
237 observed_at_unix_ms,
238 source_attachments,
239 }
240 }
241
242 fn validate(
243 &self,
244 verification_store_fence: &StoreMutationFence,
245 verified_at_unix_ms: u64,
246 ) -> Result<(), ToolOutcomeError> {
247 positive(
248 "participant_query.source_record_version",
249 self.source_record_version,
250 )?;
251 positive(
252 "participant_query.source_commit_index",
253 self.source_commit_index,
254 )?;
255 positive("participant_query.observed_at", self.observed_at_unix_ms)?;
256 validate_successor_fence(&self.source_store_fence, verification_store_fence)?;
257 if self.observed_at_unix_ms > verified_at_unix_ms {
258 return Err(ToolOutcomeError::Binding("participant_query.trusted_time"));
259 }
260 Ok(())
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266struct VerifiedParticipantNoEffectEvidenceV1 {
267 participant: ReleaseParticipantV1,
268 disposition: ParticipantNoEffectDispositionV1,
269 operation_id: AdmissionOperationId,
270 operation_version: u64,
271 observed_state: AdmissionOperationState,
272 source_record: ParticipantQueryRecordV1,
273 pub(super) evidence_digest: AdmissionDigest,
274}
275
276#[derive(Serialize)]
277struct ParticipantNoEffectEvidenceBody<'a> {
278 participant: ReleaseParticipantV1,
279 disposition: ParticipantNoEffectDispositionV1,
280 operation_id: &'a AdmissionOperationId,
281 operation_version: u64,
282 observed_state: AdmissionOperationState,
283 source_record: &'a ParticipantQueryRecordV1,
284}
285
286impl VerifiedParticipantNoEffectEvidenceV1 {
287 fn body(&self) -> ParticipantNoEffectEvidenceBody<'_> {
288 ParticipantNoEffectEvidenceBody {
289 participant: self.participant,
290 disposition: self.disposition,
291 operation_id: &self.operation_id,
292 operation_version: self.operation_version,
293 observed_state: self.observed_state,
294 source_record: &self.source_record,
295 }
296 }
297
298 fn from_verified_source(
299 operation: &AdmissionOperationV1,
300 participant: ReleaseParticipantV1,
301 disposition: ParticipantNoEffectDispositionV1,
302 source_record: ParticipantQueryRecordV1,
303 ) -> Result<Self, ToolOutcomeError> {
304 let operation_id = operation.binding().operation_id().clone();
305 let operation_version = operation.version();
306 let observed_state = operation.state();
307 let evidence_digest = domain_digest(
308 "chio.participant-no-effect-evidence.v1",
309 &ParticipantNoEffectEvidenceBody {
310 participant,
311 disposition,
312 operation_id: &operation_id,
313 operation_version,
314 observed_state,
315 source_record: &source_record,
316 },
317 )?;
318 Ok(Self {
319 participant,
320 disposition,
321 operation_id,
322 operation_version,
323 observed_state,
324 source_record,
325 evidence_digest,
326 })
327 }
328
329 fn validate_for(
330 &self,
331 operation: &AdmissionOperationV1,
332 participant: ReleaseParticipantV1,
333 disposition: ParticipantNoEffectDispositionV1,
334 verification_store_fence: &StoreMutationFence,
335 verified_at_unix_ms: u64,
336 ) -> Result<(), ToolOutcomeError> {
337 self.source_record
338 .validate(verification_store_fence, verified_at_unix_ms)?;
339 let expected_attachments = participant_attachments(operation, participant);
340 let attachments_match = match disposition {
341 ParticipantNoEffectDispositionV1::NeverAcquired
342 | ParticipantNoEffectDispositionV1::NotDispatched => {
343 self.source_record.source_attachments.is_empty()
344 }
345 ParticipantNoEffectDispositionV1::ReleasedBeforeDispatch => {
346 !expected_attachments.is_empty()
347 && self.source_record.source_attachments == expected_attachments
348 }
349 };
350 if self.participant != participant
351 || self.disposition != disposition
352 || self.operation_id != *operation.binding().operation_id()
353 || self.operation_version != operation.version()
354 || self.observed_state != operation.state()
355 || !attachments_match
356 || self.evidence_digest
357 != domain_digest("chio.participant-no-effect-evidence.v1", &self.body())?
358 {
359 return Err(ToolOutcomeError::Binding(
360 "predispatch.participant_evidence",
361 ));
362 }
363 Ok(())
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(tag = "disposition", rename_all = "snake_case")]
369enum VerifiedParticipantNoEffectV1 {
370 NotRequired,
371 NeverAcquired {
372 evidence: VerifiedParticipantNoEffectEvidenceV1,
373 },
374 ReleasedBeforeDispatch {
375 evidence: VerifiedParticipantNoEffectEvidenceV1,
376 },
377 NotDispatched {
378 evidence: VerifiedParticipantNoEffectEvidenceV1,
379 },
380}
381
382impl VerifiedParticipantNoEffectV1 {
383 #[cfg(test)]
384 fn never_acquired(
385 operation: &AdmissionOperationV1,
386 participant: ReleaseParticipantV1,
387 source_record: ParticipantQueryRecordV1,
388 ) -> Result<Self, ToolOutcomeError> {
389 Ok(Self::NeverAcquired {
390 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
391 operation,
392 participant,
393 ParticipantNoEffectDispositionV1::NeverAcquired,
394 source_record,
395 )?,
396 })
397 }
398
399 fn released_before_dispatch(
400 operation: &AdmissionOperationV1,
401 participant: ReleaseParticipantV1,
402 source_record: ParticipantQueryRecordV1,
403 ) -> Result<Self, ToolOutcomeError> {
404 Ok(Self::ReleasedBeforeDispatch {
405 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
406 operation,
407 participant,
408 ParticipantNoEffectDispositionV1::ReleasedBeforeDispatch,
409 source_record,
410 )?,
411 })
412 }
413
414 #[cfg(test)]
415 fn not_dispatched(
416 operation: &AdmissionOperationV1,
417 source_record: ParticipantQueryRecordV1,
418 ) -> Result<Self, ToolOutcomeError> {
419 Ok(Self::NotDispatched {
420 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
421 operation,
422 ReleaseParticipantV1::Transport,
423 ParticipantNoEffectDispositionV1::NotDispatched,
424 source_record,
425 )?,
426 })
427 }
428
429 fn validate_for(
430 &self,
431 operation: &AdmissionOperationV1,
432 participant: ReleaseParticipantV1,
433 required: bool,
434 verification_store_fence: &StoreMutationFence,
435 verified_at_unix_ms: u64,
436 ) -> Result<(), ToolOutcomeError> {
437 let attached = !participant_attachments(operation, participant).is_empty();
438 match self {
439 Self::NotRequired if !required => Ok(()),
440 Self::NeverAcquired { evidence } if required && !attached => evidence.validate_for(
441 operation,
442 participant,
443 ParticipantNoEffectDispositionV1::NeverAcquired,
444 verification_store_fence,
445 verified_at_unix_ms,
446 ),
447 Self::ReleasedBeforeDispatch { evidence }
448 if required && attached && participant != ReleaseParticipantV1::Channel =>
449 {
450 evidence.validate_for(
451 operation,
452 participant,
453 ParticipantNoEffectDispositionV1::ReleasedBeforeDispatch,
454 verification_store_fence,
455 verified_at_unix_ms,
456 )
457 }
458 Self::NotDispatched { evidence }
459 if participant == ReleaseParticipantV1::Transport
460 && required
461 && operation.dispatch_commit().is_none() =>
462 {
463 evidence.validate_for(
464 operation,
465 participant,
466 ParticipantNoEffectDispositionV1::NotDispatched,
467 verification_store_fence,
468 verified_at_unix_ms,
469 )
470 }
471 _ => Err(ToolOutcomeError::Binding(
472 "predispatch.participant_disposition",
473 )),
474 }
475 }
476}
477
478fn participant_attachments(
479 operation: &AdmissionOperationV1,
480 participant: ReleaseParticipantV1,
481) -> Vec<AdmissionAttachment> {
482 let kinds: &[AdmissionAttachmentKind] = match participant {
483 ReleaseParticipantV1::Broker => &[AdmissionAttachmentKind::BrokerAttempt],
484 ReleaseParticipantV1::Budget => &[AdmissionAttachmentKind::BudgetHold],
485 ReleaseParticipantV1::Approval => &[
486 AdmissionAttachmentKind::ThresholdProposal,
487 AdmissionAttachmentKind::ApprovalSet,
488 ],
489 ReleaseParticipantV1::Nonce => &[AdmissionAttachmentKind::ExecutionNonce],
490 ReleaseParticipantV1::OutcomeEligibility => &[AdmissionAttachmentKind::OutcomeEligibility],
491 ReleaseParticipantV1::Payment => &[AdmissionAttachmentKind::PaymentParticipant],
492 ReleaseParticipantV1::CreditExposure => {
493 &[AdmissionAttachmentKind::CreditExposureReservation]
494 }
495 ReleaseParticipantV1::Channel => &[AdmissionAttachmentKind::ChannelReservation],
496 ReleaseParticipantV1::Transport => &[],
497 };
498 kinds
499 .iter()
500 .filter_map(|kind| operation.attachment(*kind).cloned())
501 .collect()
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(deny_unknown_fields)]
506struct PreDispatchParticipantDispositionsV1 {
507 broker: VerifiedParticipantNoEffectV1,
508 budget: VerifiedParticipantNoEffectV1,
509 approval: VerifiedParticipantNoEffectV1,
510 nonce: VerifiedParticipantNoEffectV1,
511 outcome_eligibility: VerifiedParticipantNoEffectV1,
512 payment: VerifiedParticipantNoEffectV1,
513 credit_exposure: VerifiedParticipantNoEffectV1,
514 channel: VerifiedParticipantNoEffectV1,
515 transport: VerifiedParticipantNoEffectV1,
516}
517
518impl PreDispatchParticipantDispositionsV1 {
519 #[cfg(test)]
520 #[allow(clippy::too_many_arguments)]
521 fn from_verified_parts(
522 broker: VerifiedParticipantNoEffectV1,
523 budget: VerifiedParticipantNoEffectV1,
524 approval: VerifiedParticipantNoEffectV1,
525 nonce: VerifiedParticipantNoEffectV1,
526 outcome_eligibility: VerifiedParticipantNoEffectV1,
527 payment: VerifiedParticipantNoEffectV1,
528 credit_exposure: VerifiedParticipantNoEffectV1,
529 channel: VerifiedParticipantNoEffectV1,
530 transport: VerifiedParticipantNoEffectV1,
531 ) -> Self {
532 Self {
533 broker,
534 budget,
535 approval,
536 nonce,
537 outcome_eligibility,
538 payment,
539 credit_exposure,
540 channel,
541 transport,
542 }
543 }
544
545 fn validate_for(
546 &self,
547 operation: &AdmissionOperationV1,
548 verification_store_fence: &StoreMutationFence,
549 verified_at_unix_ms: u64,
550 ) -> Result<(), ToolOutcomeError> {
551 let required = operation.binding().participant_requirements();
552 self.broker.validate_for(
553 operation,
554 ReleaseParticipantV1::Broker,
555 required.broker_attempt,
556 verification_store_fence,
557 verified_at_unix_ms,
558 )?;
559 self.budget.validate_for(
560 operation,
561 ReleaseParticipantV1::Budget,
562 required.budget_capture,
563 verification_store_fence,
564 verified_at_unix_ms,
565 )?;
566 self.approval.validate_for(
567 operation,
568 ReleaseParticipantV1::Approval,
569 required.approval,
570 verification_store_fence,
571 verified_at_unix_ms,
572 )?;
573 self.nonce.validate_for(
574 operation,
575 ReleaseParticipantV1::Nonce,
576 required.execution_nonce,
577 verification_store_fence,
578 verified_at_unix_ms,
579 )?;
580 self.outcome_eligibility.validate_for(
581 operation,
582 ReleaseParticipantV1::OutcomeEligibility,
583 required.outcome_eligibility,
584 verification_store_fence,
585 verified_at_unix_ms,
586 )?;
587 self.payment.validate_for(
588 operation,
589 ReleaseParticipantV1::Payment,
590 required.payment,
591 verification_store_fence,
592 verified_at_unix_ms,
593 )?;
594 self.credit_exposure.validate_for(
595 operation,
596 ReleaseParticipantV1::CreditExposure,
597 required.credit_exposure,
598 verification_store_fence,
599 verified_at_unix_ms,
600 )?;
601 self.channel.validate_for(
602 operation,
603 ReleaseParticipantV1::Channel,
604 required.channel,
605 verification_store_fence,
606 verified_at_unix_ms,
607 )?;
608 self.transport.validate_for(
609 operation,
610 ReleaseParticipantV1::Transport,
611 true,
612 verification_store_fence,
613 verified_at_unix_ms,
614 )
615 }
616}
617
618fn required_release_participants(operation: &AdmissionOperationV1) -> Vec<ReleaseParticipantV1> {
619 let required = operation.binding().participant_requirements();
620 [
621 (required.broker_attempt, ReleaseParticipantV1::Broker),
622 (required.budget_capture, ReleaseParticipantV1::Budget),
623 (required.approval, ReleaseParticipantV1::Approval),
624 (required.execution_nonce, ReleaseParticipantV1::Nonce),
625 (
626 required.outcome_eligibility,
627 ReleaseParticipantV1::OutcomeEligibility,
628 ),
629 (required.payment, ReleaseParticipantV1::Payment),
630 (
631 required.credit_exposure,
632 ReleaseParticipantV1::CreditExposure,
633 ),
634 (required.channel, ReleaseParticipantV1::Channel),
635 (true, ReleaseParticipantV1::Transport),
636 ]
637 .into_iter()
638 .filter_map(|(required, participant)| required.then_some(participant))
639 .collect()
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644struct PreDispatchParticipantManifestV1 {
645 request_binding_hash: AdmissionDigest,
646 coordinator_lease_id: AdmissionIdentifier,
647 coordinator_lease_epoch: u64,
648 verification_store_fence: StoreMutationFence,
649 verified_at_unix_ms: u64,
650 required_participants: Vec<ReleaseParticipantV1>,
651 participant_dispositions: PreDispatchParticipantDispositionsV1,
652}
653
654impl PreDispatchParticipantManifestV1 {
655 fn validate_for(&self, operation: &AdmissionOperationV1) -> Result<(), ToolOutcomeError> {
656 positive("predispatch.verified_at", self.verified_at_unix_ms)?;
657 positive(
658 "predispatch.coordinator_lease_epoch",
659 self.coordinator_lease_epoch,
660 )?;
661 validate_store_fence(&self.verification_store_fence)?;
662 if self.request_binding_hash != *operation.binding().request_binding_hash()
663 || self.required_participants != required_release_participants(operation)
664 {
665 return Err(ToolOutcomeError::Binding(
666 "predispatch.participant_manifest",
667 ));
668 }
669 self.participant_dispositions.validate_for(
670 operation,
671 &self.verification_store_fence,
672 self.verified_at_unix_ms,
673 )
674 }
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(deny_unknown_fields)]
679struct PreDispatchNoEffectSnapshotV1 {
680 operation_id: AdmissionOperationId,
681 operation_version: u64,
682 request_binding_hash: AdmissionDigest,
683 coordinator_lease_id: AdmissionIdentifier,
684 coordinator_lease_epoch: u64,
685 verification_store_fence: StoreMutationFence,
686 verified_at_unix_ms: u64,
687 participant_manifest: PreDispatchParticipantManifestV1,
688 complete_participant_query_root: AdmissionDigest,
689 verifier_policy_digest: AdmissionDigest,
690 artifacts: Vec<ImmutableReleaseArtifactV1>,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
694pub struct VerifiedPreDispatchNoEffect {
695 snapshot: Box<PreDispatchNoEffectSnapshotV1>,
696}
697
698#[derive(Deserialize)]
699#[serde(remote = "VerifiedPreDispatchNoEffect", deny_unknown_fields)]
700struct PreDispatchNoEffectWireV1 {
701 snapshot: Box<PreDispatchNoEffectSnapshotV1>,
702}
703
704#[derive(Deserialize)]
705struct UntrustedPreDispatchNoEffectWireV1(
706 #[serde(with = "PreDispatchNoEffectWireV1")] VerifiedPreDispatchNoEffect,
707);
708
709impl VerifiedPreDispatchNoEffect {
710 #[must_use]
711 pub fn operation_id(&self) -> &AdmissionOperationId {
712 &self.snapshot.operation_id
713 }
714
715 pub(crate) fn from_canonical_record_verified(
716 bytes: &[u8],
717 operation: &AdmissionOperationV1,
718 context: &AdmissionProjectionContext,
719 ) -> Result<Self, ToolOutcomeError> {
720 let UntrustedPreDispatchNoEffectWireV1(proof) = serde_json::from_slice(bytes)
721 .map_err(|_| ToolOutcomeError::Invalid("predispatch.release_proof"))?;
722 let canonical = canonical_json_bytes(&proof)
723 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
724 if canonical != bytes {
725 return Err(ToolOutcomeError::Binding(
726 "predispatch.release_proof_canonical",
727 ));
728 }
729 proof.validate_against(operation, context)?;
730 Ok(proof)
731 }
732
733 pub(crate) fn from_qualified_operation_snapshot(
734 operation: &AdmissionOperationV1,
735 context: &AdmissionProjectionContext,
736 ) -> Result<Self, ToolOutcomeError> {
737 validate_pre_dispatch_context(operation, context)?;
738 let requirements = operation.binding().participant_requirements();
739 let participant = |kind: ReleaseParticipantV1, required: bool| {
740 if !participant_attachments(operation, kind).is_empty() {
741 return Err(ToolOutcomeError::Binding(
742 if kind == ReleaseParticipantV1::Channel {
743 "predispatch.channel_cancellation_required"
744 } else {
745 "predispatch.acquired_participant"
746 },
747 ));
748 }
749 if required {
750 Ok(VerifiedParticipantNoEffectV1::NeverAcquired {
751 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
752 operation,
753 kind,
754 ParticipantNoEffectDispositionV1::NeverAcquired,
755 ParticipantQueryRecordV1::from_qualified_operation(
756 operation, kind, context,
757 )?,
758 )?,
759 })
760 } else {
761 Ok(VerifiedParticipantNoEffectV1::NotRequired)
762 }
763 };
764 let channel = participant(ReleaseParticipantV1::Channel, requirements.channel)?;
765 let participant_dispositions = PreDispatchParticipantDispositionsV1 {
766 broker: participant(ReleaseParticipantV1::Broker, requirements.broker_attempt)?,
767 budget: participant(ReleaseParticipantV1::Budget, requirements.budget_capture)?,
768 approval: participant(ReleaseParticipantV1::Approval, requirements.approval)?,
769 nonce: participant(ReleaseParticipantV1::Nonce, requirements.execution_nonce)?,
770 outcome_eligibility: participant(
771 ReleaseParticipantV1::OutcomeEligibility,
772 requirements.outcome_eligibility,
773 )?,
774 payment: participant(ReleaseParticipantV1::Payment, requirements.payment)?,
775 credit_exposure: participant(
776 ReleaseParticipantV1::CreditExposure,
777 requirements.credit_exposure,
778 )?,
779 channel,
780 transport: VerifiedParticipantNoEffectV1::NotDispatched {
781 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
782 operation,
783 ReleaseParticipantV1::Transport,
784 ParticipantNoEffectDispositionV1::NotDispatched,
785 ParticipantQueryRecordV1::from_qualified_operation(
786 operation,
787 ReleaseParticipantV1::Transport,
788 context,
789 )?,
790 )?,
791 },
792 };
793 Self::from_verified_parts(
794 operation,
795 context,
796 participant_dispositions,
797 serde_json::json!({
798 "authority": "qualified_admission_projection_store",
799 "version": 1
800 }),
801 )
802 }
803
804 pub(crate) fn from_qualified_released_operation_snapshot(
805 operation: &AdmissionOperationV1,
806 context: &AdmissionProjectionContext,
807 verifier_policy: Value,
808 ) -> Result<Self, ToolOutcomeError> {
809 validate_pre_dispatch_context(operation, context)?;
810 let requirements = operation.binding().participant_requirements();
811 let participant = |kind: ReleaseParticipantV1, required: bool| {
812 let attachments = participant_attachments(operation, kind);
813 if !required {
814 return if attachments.is_empty() {
815 Ok(VerifiedParticipantNoEffectV1::NotRequired)
816 } else {
817 Err(ToolOutcomeError::Binding(
818 "predispatch.unexpected_participant",
819 ))
820 };
821 }
822 if attachments.is_empty() {
823 return Ok(VerifiedParticipantNoEffectV1::NeverAcquired {
824 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
825 operation,
826 kind,
827 ParticipantNoEffectDispositionV1::NeverAcquired,
828 ParticipantQueryRecordV1::from_qualified_operation(
829 operation, kind, context,
830 )?,
831 )?,
832 });
833 }
834 if kind == ReleaseParticipantV1::Channel {
835 return Err(ToolOutcomeError::Binding(
836 "predispatch.channel_cancellation_required",
837 ));
838 }
839 VerifiedParticipantNoEffectV1::released_before_dispatch(
840 operation,
841 kind,
842 ParticipantQueryRecordV1::from_qualified_release(operation, kind, context)?,
843 )
844 };
845 let participant_dispositions = PreDispatchParticipantDispositionsV1 {
846 broker: participant(ReleaseParticipantV1::Broker, requirements.broker_attempt)?,
847 budget: participant(ReleaseParticipantV1::Budget, requirements.budget_capture)?,
848 approval: participant(ReleaseParticipantV1::Approval, requirements.approval)?,
849 nonce: participant(ReleaseParticipantV1::Nonce, requirements.execution_nonce)?,
850 outcome_eligibility: participant(
851 ReleaseParticipantV1::OutcomeEligibility,
852 requirements.outcome_eligibility,
853 )?,
854 payment: participant(ReleaseParticipantV1::Payment, requirements.payment)?,
855 credit_exposure: participant(
856 ReleaseParticipantV1::CreditExposure,
857 requirements.credit_exposure,
858 )?,
859 channel: participant(ReleaseParticipantV1::Channel, requirements.channel)?,
860 transport: VerifiedParticipantNoEffectV1::NotDispatched {
861 evidence: VerifiedParticipantNoEffectEvidenceV1::from_verified_source(
862 operation,
863 ReleaseParticipantV1::Transport,
864 ParticipantNoEffectDispositionV1::NotDispatched,
865 ParticipantQueryRecordV1::from_qualified_operation(
866 operation,
867 ReleaseParticipantV1::Transport,
868 context,
869 )?,
870 )?,
871 },
872 };
873 Self::from_verified_parts(
874 operation,
875 context,
876 participant_dispositions,
877 verifier_policy,
878 )
879 }
880
881 #[cfg(test)]
882 fn from_verified_snapshot(
883 operation: &AdmissionOperationV1,
884 context: &AdmissionProjectionContext,
885 participant_dispositions: PreDispatchParticipantDispositionsV1,
886 verifier_policy: Value,
887 ) -> Result<Self, ToolOutcomeError> {
888 Self::from_verified_parts(
889 operation,
890 context,
891 participant_dispositions,
892 verifier_policy,
893 )
894 }
895
896 fn from_verified_parts(
897 operation: &AdmissionOperationV1,
898 context: &AdmissionProjectionContext,
899 participant_dispositions: PreDispatchParticipantDispositionsV1,
900 verifier_policy: Value,
901 ) -> Result<Self, ToolOutcomeError> {
902 validate_pre_dispatch_context(operation, context)?;
903 let operation_id = operation.binding().operation_id().clone();
904 let participant_manifest = PreDispatchParticipantManifestV1 {
905 request_binding_hash: operation.binding().request_binding_hash().clone(),
906 coordinator_lease_id: context.coordinator_lease_id.clone(),
907 coordinator_lease_epoch: context.coordinator_lease_epoch,
908 verification_store_fence: context.store_fence.clone(),
909 verified_at_unix_ms: context.trusted_time_unix_ms,
910 required_participants: required_release_participants(operation),
911 participant_dispositions,
912 };
913 participant_manifest.validate_for(operation)?;
914 let query = ImmutableReleaseArtifactV1::new(
915 ReleaseEvidenceArtifactKindV1::ParticipantQuerySnapshot,
916 release_id(
917 "participant_query_evidence_id",
918 format!("{}:participant-query", operation_id.as_str()),
919 )?,
920 serde_json::to_value(&participant_manifest)
921 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
922 )?;
923 let policy = ImmutableReleaseArtifactV1::new(
924 ReleaseEvidenceArtifactKindV1::VerifierPolicy,
925 release_id(
926 "verifier_policy_evidence_id",
927 format!("{}:verifier-policy", operation_id.as_str()),
928 )?,
929 serde_json::to_value(VerifierPolicyArtifactV1 {
930 policy: verifier_policy,
931 })
932 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
933 )?;
934 let snapshot = PreDispatchNoEffectSnapshotV1 {
935 operation_id,
936 operation_version: operation.version(),
937 request_binding_hash: operation.binding().request_binding_hash().clone(),
938 coordinator_lease_id: context.coordinator_lease_id.clone(),
939 coordinator_lease_epoch: context.coordinator_lease_epoch,
940 verification_store_fence: context.store_fence.clone(),
941 verified_at_unix_ms: context.trusted_time_unix_ms,
942 participant_manifest,
943 complete_participant_query_root: query.digest.clone(),
944 verifier_policy_digest: policy.digest.clone(),
945 artifacts: vec![query, policy],
946 };
947 let proof = Self {
948 snapshot: Box::new(snapshot),
949 };
950 proof.validate_against(operation, context)?;
951 Ok(proof)
952 }
953
954 pub(crate) fn validate_against(
955 &self,
956 operation: &AdmissionOperationV1,
957 context: &AdmissionProjectionContext,
958 ) -> Result<(), ToolOutcomeError> {
959 validate_pre_dispatch_context(operation, context)?;
960 self.snapshot.participant_manifest.validate_for(operation)?;
961 validate_successor_fence(
962 &self.snapshot.verification_store_fence,
963 &context.store_fence,
964 )?;
965 if self.snapshot.operation_id != *operation.binding().operation_id()
966 || self.snapshot.operation_version != operation.version()
967 || self.snapshot.operation_version != context.expected_operation_version
968 || self.snapshot.request_binding_hash != *operation.binding().request_binding_hash()
969 || self.snapshot.coordinator_lease_epoch != context.coordinator_lease_epoch
970 || self.snapshot.verified_at_unix_ms > context.trusted_time_unix_ms
971 || (self.snapshot.verification_store_fence == context.store_fence
972 && self.snapshot.coordinator_lease_id != context.coordinator_lease_id)
973 || self.snapshot.participant_manifest.request_binding_hash
974 != self.snapshot.request_binding_hash
975 || self.snapshot.participant_manifest.coordinator_lease_id
976 != self.snapshot.coordinator_lease_id
977 || self.snapshot.participant_manifest.coordinator_lease_epoch
978 != self.snapshot.coordinator_lease_epoch
979 || self.snapshot.participant_manifest.verification_store_fence
980 != self.snapshot.verification_store_fence
981 || self.snapshot.participant_manifest.verified_at_unix_ms
982 != self.snapshot.verified_at_unix_ms
983 || self.snapshot.artifacts.len() != 2
984 || self.snapshot.artifacts[0].kind
985 != ReleaseEvidenceArtifactKindV1::ParticipantQuerySnapshot
986 || self.snapshot.artifacts[1].kind != ReleaseEvidenceArtifactKindV1::VerifierPolicy
987 || self.snapshot.complete_participant_query_root != self.snapshot.artifacts[0].digest
988 || self.snapshot.verifier_policy_digest != self.snapshot.artifacts[1].digest
989 {
990 return Err(ToolOutcomeError::Binding("predispatch.projection_context"));
991 }
992 let artifact_manifest: PreDispatchParticipantManifestV1 =
993 parse_artifact_value(&self.snapshot.artifacts[0])?;
994 let _: VerifierPolicyArtifactV1 = parse_artifact_value(&self.snapshot.artifacts[1])?;
995 if self.snapshot.participant_manifest != artifact_manifest {
996 return Err(ToolOutcomeError::Binding(
997 "predispatch.participant_artifact",
998 ));
999 }
1000 self.snapshot
1001 .artifacts
1002 .iter()
1003 .try_for_each(ImmutableReleaseArtifactV1::validate)
1004 }
1005}
1006
1007mod predispatch_authority_sealed {
1008 pub trait Sealed {}
1009}
1010
1011#[allow(dead_code)]
1012pub(crate) trait QualifiedPreDispatchNoEffectAuthority:
1013 predispatch_authority_sealed::Sealed + Send + Sync
1014{
1015 fn verify_no_effect(
1016 &self,
1017 operation: &AdmissionOperationV1,
1018 context: &AdmissionProjectionContext,
1019 ) -> Result<VerifiedPreDispatchNoEffect, ToolOutcomeError>;
1020}
1021
1022fn validate_pre_dispatch_context(
1023 operation: &AdmissionOperationV1,
1024 context: &AdmissionProjectionContext,
1025) -> Result<(), ToolOutcomeError> {
1026 context
1027 .validate()
1028 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1029 operation
1030 .validate()
1031 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1032 if operation.dispatch_commit().is_some()
1033 || !matches!(
1034 operation.state(),
1035 crate::admission_operation::AdmissionOperationState::Prepared
1036 | crate::admission_operation::AdmissionOperationState::BrokerAttemptRegistered
1037 | crate::admission_operation::AdmissionOperationState::BudgetAuthorized
1038 | crate::admission_operation::AdmissionOperationState::ApprovalReserved
1039 | crate::admission_operation::AdmissionOperationState::ReadyToDispatch
1040 | crate::admission_operation::AdmissionOperationState::CapturePending
1041 )
1042 || context.operation_id != *operation.binding().operation_id()
1043 || context.request_id != operation.replay_key().request_id
1044 || context.expected_operation_version != operation.version()
1045 || context.coordinator_lease_epoch != operation.coordinator_lease_epoch()
1046 {
1047 return Err(ToolOutcomeError::Binding("predispatch.operation_context"));
1048 }
1049 Ok(())
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(deny_unknown_fields)]
1054struct SignedTransportStatusArtifactV1 {
1055 cancellation: ProviderCancellationBindingV1,
1056 no_acceptance_proof_base64: String,
1057 qualification_digest: AdmissionDigest,
1058 observed_at_unix_ms: u64,
1059 verifier_identity: AdmissionIdentifier,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1063#[serde(deny_unknown_fields)]
1064struct EconomicEffectCancellationArtifactV1 {
1065 slot: EconomicEffectSlotV1,
1066 expected_head_version: u64,
1067 expected_head_digest: AdmissionDigest,
1068 expected_lifecycle_fence: u64,
1069 resulting_head_version: u64,
1070 resulting_head_digest: AdmissionDigest,
1071 resulting_lifecycle_fence: u64,
1072 checkpoint_sequence: u64,
1073 checkpoint_digest: AdmissionDigest,
1074}
1075
1076#[derive(Debug, Clone, Serialize)]
1077pub struct VerifiedTransportNotAccepted {
1078 operation_id: AdmissionOperationId,
1079 operation_version: u64,
1080 request_id: AdmissionIdentifier,
1081 request_binding_hash: AdmissionDigest,
1082 dispatch_operation_version: u64,
1083 dispatch_fence: u64,
1084 projection_coordinator_lease_id: AdmissionIdentifier,
1085 projection_coordinator_lease_epoch: u64,
1086 projection_store_fence: StoreMutationFence,
1087 transport_attempt_id: AdmissionIdentifier,
1088 transport_identity: AdmissionIdentifier,
1089 transport_key_epoch: u64,
1090 signed_status_digest: AdmissionDigest,
1091 qualification_digest: AdmissionDigest,
1092 cancellation_fence: u64,
1093 verified_at_unix_ms: u64,
1094 verifier_identity: AdmissionIdentifier,
1095 monotonic_checkpoint_digest: AdmissionDigest,
1096 verifier_policy_digest: AdmissionDigest,
1097 artifacts: Vec<ImmutableReleaseArtifactV1>,
1098}
1099
1100#[derive(Deserialize)]
1101#[serde(remote = "VerifiedTransportNotAccepted", deny_unknown_fields)]
1102struct TransportNotAcceptedWireV1 {
1103 operation_id: AdmissionOperationId,
1104 operation_version: u64,
1105 request_id: AdmissionIdentifier,
1106 request_binding_hash: AdmissionDigest,
1107 dispatch_operation_version: u64,
1108 dispatch_fence: u64,
1109 projection_coordinator_lease_id: AdmissionIdentifier,
1110 projection_coordinator_lease_epoch: u64,
1111 projection_store_fence: StoreMutationFence,
1112 transport_attempt_id: AdmissionIdentifier,
1113 transport_identity: AdmissionIdentifier,
1114 transport_key_epoch: u64,
1115 signed_status_digest: AdmissionDigest,
1116 qualification_digest: AdmissionDigest,
1117 cancellation_fence: u64,
1118 verified_at_unix_ms: u64,
1119 verifier_identity: AdmissionIdentifier,
1120 monotonic_checkpoint_digest: AdmissionDigest,
1121 verifier_policy_digest: AdmissionDigest,
1122 artifacts: Vec<ImmutableReleaseArtifactV1>,
1123}
1124
1125#[derive(Deserialize)]
1126struct UntrustedTransportNotAcceptedWireV1(
1127 #[serde(with = "TransportNotAcceptedWireV1")] VerifiedTransportNotAccepted,
1128);
1129
1130impl VerifiedTransportNotAccepted {
1131 pub(crate) fn uses_economic_effect_cancellation(&self) -> bool {
1132 matches!(
1133 self.artifacts.first().map(|artifact| artifact.kind),
1134 Some(ReleaseEvidenceArtifactKindV1::EconomicEffectCancellation)
1135 )
1136 }
1137
1138 pub(crate) fn from_canonical_record_verified(
1139 bytes: &[u8],
1140 operation: &AdmissionOperationV1,
1141 context: &AdmissionProjectionContext,
1142 ) -> Result<Self, ToolOutcomeError> {
1143 let UntrustedTransportNotAcceptedWireV1(proof) = serde_json::from_slice(bytes)
1144 .map_err(|_| ToolOutcomeError::Invalid("transport_not_accepted.release_proof"))?;
1145 let canonical = canonical_json_bytes(&proof)
1146 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1147 if canonical != bytes {
1148 return Err(ToolOutcomeError::Binding(
1149 "transport_not_accepted.release_proof_canonical",
1150 ));
1151 }
1152 proof.validate_against(operation, context)?;
1153 Ok(proof)
1154 }
1155
1156 pub(crate) fn from_verified_economic_effect(
1157 cancellation: &VerifiedEconomicEffectCancellationAdvance,
1158 operation: &AdmissionOperationV1,
1159 context: &AdmissionProjectionContext,
1160 ) -> Result<Self, ToolOutcomeError> {
1161 validate_projection_context(operation, context)?;
1162 if cancellation.kind() != EconomicNoEffectKindV1::VerifiedTransportNotAccepted
1163 || operation.state() != AdmissionOperationState::DispatchCommitted
1164 {
1165 return Err(ToolOutcomeError::Binding(
1166 "transport_not_accepted.economic_kind",
1167 ));
1168 }
1169 let commit = operation
1170 .dispatch_commit()
1171 .ok_or(ToolOutcomeError::Binding(
1172 "transport_not_accepted.dispatch_commit",
1173 ))?;
1174 validate_retained_dispatch_commit(operation, commit)?;
1175 let attempt = operation
1176 .provider_attempt()
1177 .ok_or(ToolOutcomeError::Binding(
1178 "transport_not_accepted.provider_attempt",
1179 ))?;
1180 let slot = cancellation.slot();
1181 slot.validate()
1182 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1183 let Some(EconomicEffectTerminalV1::NoEffect {
1184 kind: EconomicNoEffectKindV1::VerifiedTransportNotAccepted,
1185 proof_id,
1186 proof_digest,
1187 ..
1188 }) = &slot.terminal
1189 else {
1190 return Err(ToolOutcomeError::Binding(
1191 "transport_not_accepted.economic_terminal",
1192 ));
1193 };
1194 if slot.state != EconomicEffectStateV1::NoEffect
1195 || slot.operation_id != operation.binding().operation_id().as_str()
1196 || slot.request.request_namespace_digest
1197 != operation.replay_key().request_namespace_digest.as_str()
1198 || slot.request.request_id != operation.replay_key().request_id.as_str()
1199 || slot.request.request_binding_digest
1200 != operation.binding().request_binding_hash().as_str()
1201 || slot.admission_handoff.state != EconomicAdmissionHandoffStateV1::DispatchCommitted
1202 || slot.admission_handoff.operation_version != operation.version()
1203 || slot.admission_handoff.lifecycle_fence != operation.coordinator_lease_epoch()
1204 || slot.admission_handoff.store_fence != commit.store_fence
1205 || slot.target.target_id != attempt.transport_id
1206 || slot.target.target_key_epoch != attempt.transport_key_epoch
1207 || cancellation.resulting_head_version() <= cancellation.expected_head_version()
1208 {
1209 return Err(ToolOutcomeError::Binding(
1210 "transport_not_accepted.economic_binding",
1211 ));
1212 }
1213 let cancellation_artifact = EconomicEffectCancellationArtifactV1 {
1214 slot: slot.clone(),
1215 expected_head_version: cancellation.expected_head_version(),
1216 expected_head_digest: imported_digest(
1217 "economic_cancellation.expected_head_digest",
1218 cancellation.expected_head_digest(),
1219 )?,
1220 expected_lifecycle_fence: cancellation.expected_lifecycle_fence(),
1221 resulting_head_version: cancellation.resulting_head_version(),
1222 resulting_head_digest: imported_digest(
1223 "economic_cancellation.resulting_head_digest",
1224 cancellation.resulting_head_digest(),
1225 )?,
1226 resulting_lifecycle_fence: cancellation.resulting_lifecycle_fence(),
1227 checkpoint_sequence: cancellation.checkpoint_sequence(),
1228 checkpoint_digest: imported_digest(
1229 "economic_cancellation.checkpoint_digest",
1230 cancellation.checkpoint_digest(),
1231 )?,
1232 };
1233 let cancellation_evidence = ImmutableReleaseArtifactV1::new(
1234 ReleaseEvidenceArtifactKindV1::EconomicEffectCancellation,
1235 release_id("economic_cancellation_evidence_id", proof_id.clone())?,
1236 serde_json::to_value(&cancellation_artifact)
1237 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1238 )?;
1239 let policy = ImmutableReleaseArtifactV1::new(
1240 ReleaseEvidenceArtifactKindV1::VerifierPolicy,
1241 release_id(
1242 "verifier_policy_evidence_id",
1243 format!("{}:economic-anchor-policy", slot.operation_id),
1244 )?,
1245 serde_json::to_value(VerifierPolicyArtifactV1 {
1246 policy: serde_json::json!({
1247 "anchorId": slot.anchor_id,
1248 "namespace": slot.namespace,
1249 "targetQualificationDigest": slot.target.qualification_digest,
1250 }),
1251 })
1252 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1253 )?;
1254 let proof = Self {
1255 operation_id: operation.binding().operation_id().clone(),
1256 operation_version: operation.version(),
1257 request_id: operation.replay_key().request_id,
1258 request_binding_hash: operation.binding().request_binding_hash().clone(),
1259 dispatch_operation_version: commit.committed_version,
1260 dispatch_fence: commit.store_fence.owner_epoch,
1261 projection_coordinator_lease_id: context.coordinator_lease_id.clone(),
1262 projection_coordinator_lease_epoch: context.coordinator_lease_epoch,
1263 projection_store_fence: context.store_fence.clone(),
1264 transport_attempt_id: release_id("transport_attempt_id", attempt.attempt_id.clone())?,
1265 transport_identity: release_id("transport_identity", attempt.transport_id.clone())?,
1266 transport_key_epoch: attempt.transport_key_epoch,
1267 signed_status_digest: imported_digest(
1268 "transport_not_accepted.signed_status_digest",
1269 proof_digest,
1270 )?,
1271 qualification_digest: imported_digest(
1272 "transport_not_accepted.qualification_digest",
1273 slot.target.qualification_digest.clone(),
1274 )?,
1275 cancellation_fence: cancellation.resulting_head_version(),
1276 verified_at_unix_ms: context.trusted_time_unix_ms,
1277 verifier_identity: release_id(
1278 "transport_not_accepted.verifier_identity",
1279 slot.anchor_id.clone(),
1280 )?,
1281 monotonic_checkpoint_digest: cancellation_artifact.checkpoint_digest.clone(),
1282 verifier_policy_digest: policy.digest.clone(),
1283 artifacts: vec![cancellation_evidence, policy],
1284 };
1285 proof.validate_against(operation, context)?;
1286 Ok(proof)
1287 }
1288
1289 pub(crate) fn verify_economic_cancellation_binding(
1290 &self,
1291 slot: &EconomicEffectSlotV1,
1292 expected_head: &EconomicResourceHeadV1,
1293 resulting_head: &EconomicResourceHeadV1,
1294 batch: &EconomicStateBatchV1,
1295 ) -> Result<(), ToolOutcomeError> {
1296 let artifact: EconomicEffectCancellationArtifactV1 = self
1297 .artifacts
1298 .first()
1299 .ok_or(ToolOutcomeError::Binding(
1300 "transport_not_accepted.economic_artifact_shape",
1301 ))
1302 .and_then(parse_artifact_value)?;
1303 let expected = EconomicEffectCancellationArtifactV1 {
1304 slot: slot.clone(),
1305 expected_head_version: expected_head.head_version,
1306 expected_head_digest: imported_digest(
1307 "economic_cancellation.expected_head_digest",
1308 expected_head
1309 .digest()
1310 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1311 )?,
1312 expected_lifecycle_fence: expected_head.lifecycle_fence,
1313 resulting_head_version: resulting_head.head_version,
1314 resulting_head_digest: imported_digest(
1315 "economic_cancellation.resulting_head_digest",
1316 resulting_head
1317 .digest()
1318 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1319 )?,
1320 resulting_lifecycle_fence: resulting_head.lifecycle_fence,
1321 checkpoint_sequence: batch.checkpoint_sequence,
1322 checkpoint_digest: imported_digest(
1323 "economic_cancellation.checkpoint_digest",
1324 batch.checkpoint_digest.clone(),
1325 )?,
1326 };
1327 if artifact != expected {
1328 return Err(ToolOutcomeError::Binding(
1329 "transport_not_accepted.economic_cancellation_binding",
1330 ));
1331 }
1332 Ok(())
1333 }
1334
1335 #[allow(dead_code)]
1336 #[allow(clippy::too_many_arguments)]
1337 pub(crate) fn from_verified_provider(
1338 status: &VerifiedProviderNotAccepted,
1339 qualification: &QualifiedDispatchStatusProvider,
1340 query: &DispatchStatusQuery,
1341 operation: &AdmissionOperationV1,
1342 commit: &AdmissionDispatchCommitBindingV1,
1343 context: &AdmissionProjectionContext,
1344 verifier_identity: AdmissionIdentifier,
1345 verifier_policy: Value,
1346 ) -> Result<Self, ToolOutcomeError> {
1347 query
1348 .validate()
1349 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1350 status
1351 .checkpoint()
1352 .validate()
1353 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1354 status
1355 .cancellation()
1356 .validate()
1357 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1358 positive("transport_not_accepted.observed_at", query.observed_at)?;
1359 positive(
1360 "transport_not_accepted.cancelled_at",
1361 status.cancellation().cancelled_at,
1362 )?;
1363 positive(
1364 "transport_not_accepted.cancellation_fence",
1365 status.cancellation().cancellation_fence,
1366 )?;
1367 validate_retained_dispatch_commit(operation, commit)?;
1368 validate_projection_context(operation, context)?;
1369 let attempt = &status.checkpoint().attempt;
1370 let invocation_blob = status.checkpoint().phase.invocation_blob();
1371 let ProviderAttemptPhaseV1::Cancelled {
1372 cancellation: checkpoint_cancellation,
1373 ..
1374 } = &status.checkpoint().phase
1375 else {
1376 return Err(ToolOutcomeError::Binding(
1377 "transport_not_accepted.checkpoint_phase",
1378 ));
1379 };
1380 if query.attempt != *attempt
1381 || status.cancellation() != checkpoint_cancellation
1382 || query.observed_at < status.cancellation().cancelled_at
1383 || query.observed_at > context.trusted_time_unix_ms
1384 || attempt.operation_id != operation.binding().operation_id().as_str()
1385 || operation.provider_attempt() != Some(attempt)
1386 || invocation_blob.request_digest != operation.binding().request_binding_hash().as_str()
1387 || attempt.transport_id != qualification.transport_id()
1388 || attempt.transport_key_epoch != qualification.transport_key_epoch()
1389 || status.qualification_digest() != qualification.qualification_digest()
1390 {
1391 return Err(ToolOutcomeError::Binding("transport_not_accepted.provider"));
1392 }
1393 let operation_id = operation.binding().operation_id().clone();
1394 let cancellation_digest = imported_digest(
1395 "transport_not_accepted.signed_status_digest",
1396 status
1397 .cancellation()
1398 .digest()
1399 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1400 )?;
1401 let qualification_digest = imported_digest(
1402 "transport_not_accepted.qualification_digest",
1403 status.qualification_digest().to_owned(),
1404 )?;
1405 let signed_status = ImmutableReleaseArtifactV1::new(
1406 ReleaseEvidenceArtifactKindV1::SignedTransportStatus,
1407 release_id(
1408 "signed_transport_status_evidence_id",
1409 status.cancellation().cancellation_ref.clone(),
1410 )?,
1411 serde_json::to_value(SignedTransportStatusArtifactV1 {
1412 cancellation: status.cancellation().clone(),
1413 no_acceptance_proof_base64: BASE64.encode(status.proof()),
1414 qualification_digest: qualification_digest.clone(),
1415 observed_at_unix_ms: query.observed_at,
1416 verifier_identity: verifier_identity.clone(),
1417 })
1418 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1419 )?;
1420 let checkpoint = ImmutableReleaseArtifactV1::new(
1421 ReleaseEvidenceArtifactKindV1::MonotonicAttemptCheckpoint,
1422 release_id(
1423 "checkpoint_evidence_id",
1424 format!("{}:checkpoint", attempt.attempt_id),
1425 )?,
1426 serde_json::to_value(status.checkpoint())
1427 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1428 )?;
1429 let policy = ImmutableReleaseArtifactV1::new(
1430 ReleaseEvidenceArtifactKindV1::VerifierPolicy,
1431 release_id(
1432 "verifier_policy_evidence_id",
1433 format!("{}:verifier-policy", operation_id.as_str()),
1434 )?,
1435 serde_json::to_value(VerifierPolicyArtifactV1 {
1436 policy: verifier_policy,
1437 })
1438 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1439 )?;
1440 let proof = Self {
1441 operation_id,
1442 operation_version: operation.version(),
1443 request_id: operation.replay_key().request_id,
1444 request_binding_hash: operation.binding().request_binding_hash().clone(),
1445 dispatch_operation_version: commit.committed_version,
1446 dispatch_fence: commit.store_fence.owner_epoch,
1447 projection_coordinator_lease_id: context.coordinator_lease_id.clone(),
1448 projection_coordinator_lease_epoch: context.coordinator_lease_epoch,
1449 projection_store_fence: context.store_fence.clone(),
1450 transport_attempt_id: release_id("transport_attempt_id", attempt.attempt_id.clone())?,
1451 transport_identity: release_id("transport_identity", attempt.transport_id.clone())?,
1452 transport_key_epoch: attempt.transport_key_epoch,
1453 signed_status_digest: cancellation_digest,
1454 qualification_digest,
1455 cancellation_fence: status.cancellation().cancellation_fence,
1456 verified_at_unix_ms: context.trusted_time_unix_ms,
1457 verifier_identity,
1458 monotonic_checkpoint_digest: imported_digest(
1459 "transport_not_accepted.checkpoint_digest",
1460 status.checkpoint_digest().to_owned(),
1461 )?,
1462 verifier_policy_digest: policy.digest.clone(),
1463 artifacts: vec![signed_status, checkpoint, policy],
1464 };
1465 proof.validate_against(operation, context)?;
1466 Ok(proof)
1467 }
1468
1469 pub(crate) fn validate_against(
1470 &self,
1471 operation: &AdmissionOperationV1,
1472 context: &AdmissionProjectionContext,
1473 ) -> Result<(), ToolOutcomeError> {
1474 validate_projection_context(operation, context)?;
1475 let commit = operation
1476 .dispatch_commit()
1477 .ok_or(ToolOutcomeError::Binding(
1478 "transport_not_accepted.dispatch_commit",
1479 ))?;
1480 validate_retained_dispatch_commit(operation, commit)?;
1481 validate_successor_fence(&self.projection_store_fence, &context.store_fence)?;
1482 if self.operation_id != *operation.binding().operation_id()
1483 || self.operation_id != context.operation_id
1484 || self.request_id != operation.replay_key().request_id
1485 || self.request_id != context.request_id
1486 || self.request_binding_hash != *operation.binding().request_binding_hash()
1487 || self.operation_version != operation.version()
1488 || self.operation_version != context.expected_operation_version
1489 || self.dispatch_operation_version != commit.committed_version
1490 || self.dispatch_fence != commit.store_fence.owner_epoch
1491 || self.projection_coordinator_lease_epoch != context.coordinator_lease_epoch
1492 || self.verified_at_unix_ms == 0
1493 || self.verified_at_unix_ms > context.trusted_time_unix_ms
1494 || (self.projection_store_fence == context.store_fence
1495 && self.projection_coordinator_lease_id != context.coordinator_lease_id)
1496 {
1497 return Err(ToolOutcomeError::Binding(
1498 "transport_not_accepted.projection_context",
1499 ));
1500 }
1501 match self.artifacts.first().map(|artifact| artifact.kind) {
1502 Some(ReleaseEvidenceArtifactKindV1::SignedTransportStatus) => {
1503 self.validate_provider_evidence(operation)
1504 }
1505 Some(ReleaseEvidenceArtifactKindV1::EconomicEffectCancellation) => {
1506 self.validate_economic_evidence(operation)
1507 }
1508 _ => Err(ToolOutcomeError::Binding(
1509 "transport_not_accepted.artifact_kind",
1510 )),
1511 }
1512 }
1513
1514 fn validate_provider_evidence(
1515 &self,
1516 operation: &AdmissionOperationV1,
1517 ) -> Result<(), ToolOutcomeError> {
1518 if self.artifacts.len() != 3
1519 || self.artifacts[1].kind != ReleaseEvidenceArtifactKindV1::MonotonicAttemptCheckpoint
1520 || self.artifacts[2].kind != ReleaseEvidenceArtifactKindV1::VerifierPolicy
1521 || self.verifier_policy_digest != self.artifacts[2].digest
1522 {
1523 return Err(ToolOutcomeError::Binding(
1524 "transport_not_accepted.provider_artifact_shape",
1525 ));
1526 }
1527 let evidence = transport::validate_artifacts(&self.artifacts, self.verified_at_unix_ms)?;
1528 let signed = &evidence.signed;
1529 let checkpoint = &evidence.checkpoint;
1530 let attempt = &checkpoint.attempt;
1531 let invocation_blob = checkpoint.phase.invocation_blob();
1532 if signed.verifier_identity != self.verifier_identity
1533 || signed.qualification_digest != self.qualification_digest
1534 || evidence.cancellation_digest != self.signed_status_digest
1535 || signed.cancellation.cancellation_fence != self.cancellation_fence
1536 || evidence.checkpoint_digest != self.monotonic_checkpoint_digest
1537 || attempt.operation_id != self.operation_id.as_str()
1538 || attempt.attempt_id != self.transport_attempt_id.as_str()
1539 || operation.provider_attempt() != Some(attempt)
1540 || attempt.transport_id != self.transport_identity.as_str()
1541 || attempt.transport_key_epoch != self.transport_key_epoch
1542 || invocation_blob.request_digest != self.request_binding_hash.as_str()
1543 {
1544 return Err(ToolOutcomeError::Binding(
1545 "transport_not_accepted.artifacts",
1546 ));
1547 }
1548 self.artifacts
1549 .iter()
1550 .try_for_each(ImmutableReleaseArtifactV1::validate)
1551 }
1552
1553 fn validate_economic_evidence(
1554 &self,
1555 operation: &AdmissionOperationV1,
1556 ) -> Result<(), ToolOutcomeError> {
1557 if self.artifacts.len() != 2
1558 || self.artifacts[1].kind != ReleaseEvidenceArtifactKindV1::VerifierPolicy
1559 || self.verifier_policy_digest != self.artifacts[1].digest
1560 {
1561 return Err(ToolOutcomeError::Binding(
1562 "transport_not_accepted.economic_artifact_shape",
1563 ));
1564 }
1565 let artifact: EconomicEffectCancellationArtifactV1 =
1566 parse_artifact_value(&self.artifacts[0])?;
1567 let _: VerifierPolicyArtifactV1 = parse_artifact_value(&self.artifacts[1])?;
1568 artifact
1569 .slot
1570 .validate()
1571 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1572 let attempt = operation
1573 .provider_attempt()
1574 .ok_or(ToolOutcomeError::Binding(
1575 "transport_not_accepted.provider_attempt",
1576 ))?;
1577 let commit = operation
1578 .dispatch_commit()
1579 .ok_or(ToolOutcomeError::Binding(
1580 "transport_not_accepted.dispatch_commit",
1581 ))?;
1582 let Some(EconomicEffectTerminalV1::NoEffect {
1583 kind: EconomicNoEffectKindV1::VerifiedTransportNotAccepted,
1584 proof_digest,
1585 ..
1586 }) = &artifact.slot.terminal
1587 else {
1588 return Err(ToolOutcomeError::Binding(
1589 "transport_not_accepted.economic_terminal",
1590 ));
1591 };
1592 if artifact.slot.state != EconomicEffectStateV1::NoEffect
1593 || artifact.slot.operation_id != self.operation_id.as_str()
1594 || artifact.slot.request.request_namespace_digest
1595 != operation.replay_key().request_namespace_digest.as_str()
1596 || artifact.slot.request.request_id != self.request_id.as_str()
1597 || artifact.slot.request.request_binding_digest != self.request_binding_hash.as_str()
1598 || artifact.slot.admission_handoff.state
1599 != EconomicAdmissionHandoffStateV1::DispatchCommitted
1600 || artifact.slot.admission_handoff.operation_version != self.operation_version
1601 || artifact.slot.admission_handoff.lifecycle_fence
1602 != self.projection_coordinator_lease_epoch
1603 || artifact.slot.admission_handoff.store_fence != commit.store_fence
1604 || artifact.slot.target.target_id != attempt.transport_id
1605 || artifact.slot.target.target_key_epoch != attempt.transport_key_epoch
1606 || artifact.slot.target.qualification_digest != self.qualification_digest.as_str()
1607 || artifact.expected_head_version == 0
1608 || artifact.resulting_head_version <= artifact.expected_head_version
1609 || artifact.expected_lifecycle_fence == 0
1610 || artifact.resulting_lifecycle_fence <= artifact.expected_lifecycle_fence
1611 || artifact.checkpoint_sequence == 0
1612 || artifact.checkpoint_digest != self.monotonic_checkpoint_digest
1613 || artifact.resulting_head_version != self.cancellation_fence
1614 || proof_digest != self.signed_status_digest.as_str()
1615 || attempt.attempt_id != self.transport_attempt_id.as_str()
1616 || attempt.transport_id != self.transport_identity.as_str()
1617 || attempt.transport_key_epoch != self.transport_key_epoch
1618 || artifact.slot.anchor_id != self.verifier_identity.as_str()
1619 {
1620 return Err(ToolOutcomeError::Binding(
1621 "transport_not_accepted.economic_artifacts",
1622 ));
1623 }
1624 self.artifacts
1625 .iter()
1626 .try_for_each(ImmutableReleaseArtifactV1::validate)
1627 }
1628}
1629
1630#[derive(Debug, Clone, Serialize)]
1631pub struct VerifiedContractualZeroCharge {
1632 operation_id: AdmissionOperationId,
1633 request_id: AdmissionIdentifier,
1634 request_binding_hash: AdmissionDigest,
1635 pub(super) operation_version: u64,
1636 pub(super) outcome_version: u64,
1637 projection_coordinator_lease_id: AdmissionIdentifier,
1638 projection_coordinator_lease_epoch: u64,
1639 projection_store_fence: StoreMutationFence,
1640 verified_at_unix_ms: u64,
1641 tool_outcome_id: AdmissionDigest,
1642 evaluation_id: AdmissionDigest,
1643 pricing_verdict_digest: AdmissionDigest,
1644 currency: String,
1645 verifier_policy_digest: AdmissionDigest,
1646 artifacts: Vec<ImmutableReleaseArtifactV1>,
1647}
1648
1649impl VerifiedContractualZeroCharge {
1650 pub(crate) fn from_records(
1651 operation: &AdmissionOperationV1,
1652 context: &AdmissionProjectionContext,
1653 outcome: &ToolOutcomeRecordV1,
1654 evaluation: &PostReturnEvaluationRecordV1,
1655 ) -> Result<Self, ToolOutcomeError> {
1656 validate_projection_context(operation, context)?;
1657 outcome.validate_against(operation)?;
1658 ToolOutcomeTerminalEvidenceV1::from_records(operation, context, outcome, evaluation)?;
1659 let ResolvedToolOutcomeV1::Resolved {
1660 pricing_verdict_digest,
1661 settlement_disposition: SettlementDispositionV1::ContractualZeroCharge { currency },
1662 ..
1663 } = &outcome.disposition
1664 else {
1665 return Err(ToolOutcomeError::Invalid("zero_charge.disposition"));
1666 };
1667 let outcome_artifact = ImmutableReleaseArtifactV1::new(
1668 ReleaseEvidenceArtifactKindV1::TerminalToolOutcome,
1669 release_id(
1670 "terminal_tool_outcome_evidence_id",
1671 format!("{}:outcome", outcome.outcome_id.as_str()),
1672 )?,
1673 serde_json::to_value(outcome.to_persisted())
1674 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1675 )?;
1676 let evaluation_artifact = ImmutableReleaseArtifactV1::new(
1677 ReleaseEvidenceArtifactKindV1::TerminalPostReturnEvaluation,
1678 release_id(
1679 "terminal_evaluation_evidence_id",
1680 format!("{}:evaluation", evaluation.evaluation_id.as_str()),
1681 )?,
1682 serde_json::to_value(evaluation.to_persisted())
1683 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1684 )?;
1685 let policy = ImmutableReleaseArtifactV1::new(
1686 ReleaseEvidenceArtifactKindV1::VerifierPolicy,
1687 release_id(
1688 "verifier_policy_evidence_id",
1689 format!("{}:policy", evaluation.plan_digest.as_str()),
1690 )?,
1691 serde_json::to_value(VerifierPolicyArtifactV1 {
1692 policy: serde_json::json!({ "plan_digest": evaluation.plan_digest }),
1693 })
1694 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
1695 )?;
1696 let proof = Self {
1697 operation_id: operation.binding().operation_id().clone(),
1698 request_id: operation.replay_key().request_id,
1699 request_binding_hash: operation.binding().request_binding_hash().clone(),
1700 operation_version: operation.version(),
1701 outcome_version: outcome.version,
1702 projection_coordinator_lease_id: context.coordinator_lease_id.clone(),
1703 projection_coordinator_lease_epoch: context.coordinator_lease_epoch,
1704 projection_store_fence: context.store_fence.clone(),
1705 verified_at_unix_ms: context.trusted_time_unix_ms,
1706 tool_outcome_id: outcome.outcome_id.clone(),
1707 evaluation_id: evaluation.evaluation_id.clone(),
1708 pricing_verdict_digest: pricing_verdict_digest.clone(),
1709 currency: currency.clone(),
1710 verifier_policy_digest: policy.digest.clone(),
1711 artifacts: vec![outcome_artifact, evaluation_artifact, policy],
1712 };
1713 proof.validate_against(operation, context)?;
1714 Ok(proof)
1715 }
1716
1717 #[allow(dead_code)]
1718 pub(crate) fn validate_against(
1719 &self,
1720 operation: &AdmissionOperationV1,
1721 context: &AdmissionProjectionContext,
1722 ) -> Result<(), ToolOutcomeError> {
1723 validate_projection_context(operation, context)?;
1724 validate_successor_fence(&self.projection_store_fence, &context.store_fence)?;
1725 if self.operation_id != *operation.binding().operation_id()
1726 || self.request_id != operation.replay_key().request_id
1727 || self.request_id != context.request_id
1728 || self.request_binding_hash != *operation.binding().request_binding_hash()
1729 || self.operation_version != operation.version()
1730 || self.operation_version != context.expected_operation_version
1731 || self.outcome_version == 0
1732 || self.projection_coordinator_lease_epoch != context.coordinator_lease_epoch
1733 || self.verified_at_unix_ms == 0
1734 || self.verified_at_unix_ms > context.trusted_time_unix_ms
1735 || (self.projection_store_fence == context.store_fence
1736 && self.projection_coordinator_lease_id != context.coordinator_lease_id)
1737 || self.artifacts.len() != 3
1738 || self.artifacts[0].kind != ReleaseEvidenceArtifactKindV1::TerminalToolOutcome
1739 || self.artifacts[1].kind != ReleaseEvidenceArtifactKindV1::TerminalPostReturnEvaluation
1740 || self.artifacts[2].kind != ReleaseEvidenceArtifactKindV1::VerifierPolicy
1741 || self.verifier_policy_digest != self.artifacts[2].digest
1742 {
1743 return Err(ToolOutcomeError::Binding("zero_charge.projection_context"));
1744 }
1745 let outcome_persisted: PersistedToolOutcomeRecordV1 =
1746 parse_artifact_value(&self.artifacts[0])?;
1747 let evaluation_persisted: PersistedPostReturnEvaluationRecordV1 =
1748 parse_artifact_value(&self.artifacts[1])?;
1749 let _: VerifierPolicyArtifactV1 = parse_artifact_value(&self.artifacts[2])?;
1750 let outcome = ToolOutcomeRecordV1::from_persisted(outcome_persisted)?;
1751 let evaluation = PostReturnEvaluationRecordV1::from_persisted(evaluation_persisted)?;
1752 outcome.validate_against(operation)?;
1753 evaluation.validate_against(operation, &outcome)?;
1754 let ResolvedToolOutcomeV1::Resolved {
1755 evaluation_id,
1756 pricing_verdict_digest,
1757 settlement_disposition: SettlementDispositionV1::ContractualZeroCharge { currency },
1758 ..
1759 } = &outcome.disposition
1760 else {
1761 return Err(ToolOutcomeError::Binding(
1762 "zero_charge.artifact_disposition",
1763 ));
1764 };
1765 if outcome.operation_id != self.operation_id
1766 || outcome.request_id != self.request_id
1767 || outcome.outcome_id != self.tool_outcome_id
1768 || outcome.version != self.outcome_version
1769 || outcome.recorded_at_unix_ms > self.verified_at_unix_ms
1770 || evaluation.operation_id != self.operation_id
1771 || evaluation.tool_outcome_id != self.tool_outcome_id
1772 || evaluation.evaluation_id != self.evaluation_id
1773 || evaluation.trusted_time_unix_ms > self.verified_at_unix_ms
1774 || evaluation_id != &self.evaluation_id
1775 || pricing_verdict_digest != &self.pricing_verdict_digest
1776 || currency != &self.currency
1777 {
1778 return Err(ToolOutcomeError::Binding("zero_charge.artifacts"));
1779 }
1780 self.artifacts
1781 .iter()
1782 .try_for_each(ImmutableReleaseArtifactV1::validate)
1783 }
1784}
1785
1786fn validate_projection_context(
1787 operation: &AdmissionOperationV1,
1788 context: &AdmissionProjectionContext,
1789) -> Result<(), ToolOutcomeError> {
1790 context
1791 .validate()
1792 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1793 operation
1794 .validate()
1795 .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
1796 if let Some(commit) = operation.dispatch_commit() {
1797 validate_successor_fence(&commit.store_fence, &context.store_fence)?;
1798 }
1799 if context.operation_id != *operation.binding().operation_id()
1800 || context.request_id != operation.replay_key().request_id
1801 || context.expected_operation_version != operation.version()
1802 || context.coordinator_lease_epoch != operation.coordinator_lease_epoch()
1803 {
1804 return Err(ToolOutcomeError::Binding("release.projection_context"));
1805 }
1806 Ok(())
1807}
1808
1809#[derive(Debug, Clone, Serialize)]
1810#[serde(tag = "kind", rename_all = "snake_case")]
1811pub enum VerifiedNoEffectProof {
1812 BeforeDispatch(VerifiedPreDispatchNoEffect),
1813 NotAcceptedAfterDispatch(Box<VerifiedTransportNotAccepted>),
1814}
1815
1816#[derive(Debug, Clone, Serialize)]
1817#[serde(tag = "kind", rename_all = "snake_case")]
1818pub enum MonetaryReleaseAuthority {
1819 NoEffect(VerifiedNoEffectProof),
1820 ContractualZeroCharge(Box<VerifiedContractualZeroCharge>),
1821}
1822
1823#[cfg(test)]
1824mod tests;