Skip to main content

chio_kernel/tool_outcome/release/
persistence.rs

1//! Owned persisted monetary-release evidence. Decoding never restores authority.
2
3use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum MonetaryReleaseEvidenceKindV1 {
8    BeforeDispatchNoEffect,
9    NotAcceptedAfterDispatch,
10    ContractualZeroCharge,
11}
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
14pub struct MonetaryReleaseEvidenceV1 {
15    pub(super) schema: &'static str,
16    pub(super) evidence_id: AdmissionDigest,
17    pub(super) evidence_kind: MonetaryReleaseEvidenceKindV1,
18    pub(super) operation_id: AdmissionOperationId,
19    pub(super) operation_version: u64,
20    pub(super) verifier_policy_digest: AdmissionDigest,
21    pub(super) source_binding_base64: String,
22    pub(super) source_binding_digest: AdmissionDigest,
23    pub(super) source_artifacts: Vec<ImmutableReleaseArtifactV1>,
24    pub(super) bundle_digest: AdmissionDigest,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct PersistedImmutableReleaseArtifactV1 {
30    pub kind: ReleaseEvidenceArtifactKindV1,
31    pub evidence_id: AdmissionIdentifier,
32    pub digest: AdmissionDigest,
33    pub value: Value,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct PersistedMonetaryReleaseEvidenceV1 {
39    pub schema: String,
40    pub evidence_id: AdmissionDigest,
41    pub evidence_kind: MonetaryReleaseEvidenceKindV1,
42    pub operation_id: AdmissionOperationId,
43    pub operation_version: u64,
44    pub verifier_policy_digest: AdmissionDigest,
45    pub source_binding_base64: String,
46    pub source_binding_digest: AdmissionDigest,
47    pub source_artifacts: Vec<PersistedImmutableReleaseArtifactV1>,
48    pub bundle_digest: AdmissionDigest,
49}
50
51#[derive(Serialize)]
52struct ReleaseEvidenceBody<'a> {
53    schema: &'a str,
54    evidence_kind: MonetaryReleaseEvidenceKindV1,
55    operation_id: &'a AdmissionOperationId,
56    operation_version: u64,
57    verifier_policy_digest: &'a AdmissionDigest,
58    source_binding_base64: &'a str,
59    source_binding_digest: &'a AdmissionDigest,
60    source_artifacts: &'a [ImmutableReleaseArtifactV1],
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "kind", content = "binding", rename_all = "snake_case")]
65enum ReleaseSourceBindingV1 {
66    BeforeDispatchNoEffect(Box<PreDispatchReleaseSourceV1>),
67    NotAcceptedAfterDispatch(Box<TransportReleaseSourceV1>),
68    ContractualZeroCharge(Box<ZeroChargeReleaseSourceV1>),
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73struct PreDispatchReleaseSourceV1 {
74    operation_id: AdmissionOperationId,
75    operation_version: u64,
76    request_binding_hash: AdmissionDigest,
77    coordinator_lease_id: AdmissionIdentifier,
78    coordinator_lease_epoch: u64,
79    verification_store_fence: StoreMutationFence,
80    verified_at_unix_ms: u64,
81    participant_manifest: PreDispatchParticipantManifestV1,
82    complete_participant_query_root: AdmissionDigest,
83    verifier_policy_digest: AdmissionDigest,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88struct TransportReleaseSourceV1 {
89    operation_id: AdmissionOperationId,
90    operation_version: u64,
91    request_id: AdmissionIdentifier,
92    request_binding_hash: AdmissionDigest,
93    dispatch_operation_version: u64,
94    dispatch_fence: u64,
95    projection_coordinator_lease_id: AdmissionIdentifier,
96    projection_coordinator_lease_epoch: u64,
97    projection_store_fence: StoreMutationFence,
98    transport_attempt_id: AdmissionIdentifier,
99    transport_identity: AdmissionIdentifier,
100    transport_key_epoch: u64,
101    signed_status_digest: AdmissionDigest,
102    qualification_digest: AdmissionDigest,
103    cancellation_fence: u64,
104    verified_at_unix_ms: u64,
105    verifier_identity: AdmissionIdentifier,
106    monotonic_checkpoint_digest: AdmissionDigest,
107    verifier_policy_digest: AdmissionDigest,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112struct ZeroChargeReleaseSourceV1 {
113    operation_id: AdmissionOperationId,
114    request_id: AdmissionIdentifier,
115    request_binding_hash: AdmissionDigest,
116    operation_version: u64,
117    outcome_version: u64,
118    projection_coordinator_lease_id: AdmissionIdentifier,
119    projection_coordinator_lease_epoch: u64,
120    projection_store_fence: StoreMutationFence,
121    verified_at_unix_ms: u64,
122    tool_outcome_id: AdmissionDigest,
123    evaluation_id: AdmissionDigest,
124    pricing_verdict_digest: AdmissionDigest,
125    currency: String,
126    verifier_policy_digest: AdmissionDigest,
127}
128
129impl From<&ImmutableReleaseArtifactV1> for PersistedImmutableReleaseArtifactV1 {
130    fn from(value: &ImmutableReleaseArtifactV1) -> Self {
131        Self {
132            kind: value.kind,
133            evidence_id: value.evidence_id.clone(),
134            digest: value.digest.clone(),
135            value: value.value.clone(),
136        }
137    }
138}
139
140impl From<PersistedImmutableReleaseArtifactV1> for ImmutableReleaseArtifactV1 {
141    fn from(value: PersistedImmutableReleaseArtifactV1) -> Self {
142        Self {
143            kind: value.kind,
144            evidence_id: value.evidence_id,
145            digest: value.digest,
146            value: value.value,
147        }
148    }
149}
150
151impl MonetaryReleaseAuthority {
152    pub fn evidence_bundle(&self) -> Result<MonetaryReleaseEvidenceV1, ToolOutcomeError> {
153        let (kind, operation_id, operation_version, policy, artifacts, source) = match self {
154            Self::NoEffect(VerifiedNoEffectProof::BeforeDispatch(proof)) => (
155                MonetaryReleaseEvidenceKindV1::BeforeDispatchNoEffect,
156                &proof.snapshot.operation_id,
157                proof.snapshot.operation_version,
158                &proof.snapshot.verifier_policy_digest,
159                &proof.snapshot.artifacts,
160                ReleaseSourceBindingV1::BeforeDispatchNoEffect(Box::new(
161                    PreDispatchReleaseSourceV1 {
162                        operation_id: proof.snapshot.operation_id.clone(),
163                        operation_version: proof.snapshot.operation_version,
164                        request_binding_hash: proof.snapshot.request_binding_hash.clone(),
165                        coordinator_lease_id: proof.snapshot.coordinator_lease_id.clone(),
166                        coordinator_lease_epoch: proof.snapshot.coordinator_lease_epoch,
167                        verification_store_fence: proof.snapshot.verification_store_fence.clone(),
168                        verified_at_unix_ms: proof.snapshot.verified_at_unix_ms,
169                        participant_manifest: proof.snapshot.participant_manifest.clone(),
170                        complete_participant_query_root: proof
171                            .snapshot
172                            .complete_participant_query_root
173                            .clone(),
174                        verifier_policy_digest: proof.snapshot.verifier_policy_digest.clone(),
175                    },
176                )),
177            ),
178            Self::NoEffect(VerifiedNoEffectProof::NotAcceptedAfterDispatch(proof)) => (
179                MonetaryReleaseEvidenceKindV1::NotAcceptedAfterDispatch,
180                &proof.operation_id,
181                proof.operation_version,
182                &proof.verifier_policy_digest,
183                &proof.artifacts,
184                ReleaseSourceBindingV1::NotAcceptedAfterDispatch(Box::new(
185                    TransportReleaseSourceV1 {
186                        operation_id: proof.operation_id.clone(),
187                        operation_version: proof.operation_version,
188                        request_id: proof.request_id.clone(),
189                        request_binding_hash: proof.request_binding_hash.clone(),
190                        dispatch_operation_version: proof.dispatch_operation_version,
191                        dispatch_fence: proof.dispatch_fence,
192                        projection_coordinator_lease_id: proof
193                            .projection_coordinator_lease_id
194                            .clone(),
195                        projection_coordinator_lease_epoch: proof
196                            .projection_coordinator_lease_epoch,
197                        projection_store_fence: proof.projection_store_fence.clone(),
198                        transport_attempt_id: proof.transport_attempt_id.clone(),
199                        transport_identity: proof.transport_identity.clone(),
200                        transport_key_epoch: proof.transport_key_epoch,
201                        signed_status_digest: proof.signed_status_digest.clone(),
202                        qualification_digest: proof.qualification_digest.clone(),
203                        cancellation_fence: proof.cancellation_fence,
204                        verified_at_unix_ms: proof.verified_at_unix_ms,
205                        verifier_identity: proof.verifier_identity.clone(),
206                        monotonic_checkpoint_digest: proof.monotonic_checkpoint_digest.clone(),
207                        verifier_policy_digest: proof.verifier_policy_digest.clone(),
208                    },
209                )),
210            ),
211            Self::ContractualZeroCharge(proof) => (
212                MonetaryReleaseEvidenceKindV1::ContractualZeroCharge,
213                &proof.operation_id,
214                proof.operation_version,
215                &proof.verifier_policy_digest,
216                &proof.artifacts,
217                ReleaseSourceBindingV1::ContractualZeroCharge(Box::new(
218                    ZeroChargeReleaseSourceV1 {
219                        operation_id: proof.operation_id.clone(),
220                        request_id: proof.request_id.clone(),
221                        request_binding_hash: proof.request_binding_hash.clone(),
222                        operation_version: proof.operation_version,
223                        outcome_version: proof.outcome_version,
224                        projection_coordinator_lease_id: proof
225                            .projection_coordinator_lease_id
226                            .clone(),
227                        projection_coordinator_lease_epoch: proof
228                            .projection_coordinator_lease_epoch,
229                        projection_store_fence: proof.projection_store_fence.clone(),
230                        verified_at_unix_ms: proof.verified_at_unix_ms,
231                        tool_outcome_id: proof.tool_outcome_id.clone(),
232                        evaluation_id: proof.evaluation_id.clone(),
233                        pricing_verdict_digest: proof.pricing_verdict_digest.clone(),
234                        currency: proof.currency.clone(),
235                        verifier_policy_digest: proof.verifier_policy_digest.clone(),
236                    },
237                )),
238            ),
239        };
240        MonetaryReleaseEvidenceV1::new(
241            kind,
242            operation_id.clone(),
243            operation_version,
244            policy.clone(),
245            artifacts.clone(),
246            source,
247        )
248    }
249}
250
251impl MonetaryReleaseEvidenceV1 {
252    fn new(
253        evidence_kind: MonetaryReleaseEvidenceKindV1,
254        operation_id: AdmissionOperationId,
255        operation_version: u64,
256        verifier_policy_digest: AdmissionDigest,
257        source_artifacts: Vec<ImmutableReleaseArtifactV1>,
258        source: ReleaseSourceBindingV1,
259    ) -> Result<Self, ToolOutcomeError> {
260        let source_binding = canonical(&source)?;
261        let source_binding_base64 = BASE64.encode(&source_binding);
262        let source_binding_digest = digest_bytes("release.source_binding_digest", &source_binding)?;
263        let body = ReleaseEvidenceBody {
264            schema: MONETARY_RELEASE_EVIDENCE_SCHEMA,
265            evidence_kind,
266            operation_id: &operation_id,
267            operation_version,
268            verifier_policy_digest: &verifier_policy_digest,
269            source_binding_base64: &source_binding_base64,
270            source_binding_digest: &source_binding_digest,
271            source_artifacts: &source_artifacts,
272        };
273        let body_bytes = bounded(
274            "monetary_release_evidence",
275            &body,
276            MAX_MONETARY_RELEASE_EVIDENCE_BYTES,
277        )?;
278        let bundle = Self {
279            schema: MONETARY_RELEASE_EVIDENCE_SCHEMA,
280            evidence_id: domain_digest("chio.monetary-release-evidence.identity.v1", &body)?,
281            evidence_kind,
282            operation_id,
283            operation_version,
284            verifier_policy_digest,
285            source_binding_base64,
286            source_binding_digest,
287            source_artifacts,
288            bundle_digest: digest_bytes("release.bundle_digest", &body_bytes)?,
289        };
290        bundle.validate()?;
291        Ok(bundle)
292    }
293
294    #[must_use]
295    pub fn evidence_id(&self) -> &AdmissionDigest {
296        &self.evidence_id
297    }
298
299    #[must_use]
300    pub fn bundle_digest(&self) -> &AdmissionDigest {
301        &self.bundle_digest
302    }
303
304    pub fn to_persisted(&self) -> PersistedMonetaryReleaseEvidenceV1 {
305        PersistedMonetaryReleaseEvidenceV1 {
306            schema: self.schema.to_owned(),
307            evidence_id: self.evidence_id.clone(),
308            evidence_kind: self.evidence_kind,
309            operation_id: self.operation_id.clone(),
310            operation_version: self.operation_version,
311            verifier_policy_digest: self.verifier_policy_digest.clone(),
312            source_binding_base64: self.source_binding_base64.clone(),
313            source_binding_digest: self.source_binding_digest.clone(),
314            source_artifacts: self.source_artifacts.iter().map(Into::into).collect(),
315            bundle_digest: self.bundle_digest.clone(),
316        }
317    }
318
319    pub fn from_persisted(
320        value: PersistedMonetaryReleaseEvidenceV1,
321    ) -> Result<Self, ToolOutcomeError> {
322        if value.schema != MONETARY_RELEASE_EVIDENCE_SCHEMA {
323            return Err(ToolOutcomeError::Invalid("release_evidence.schema"));
324        }
325        let bundle = Self {
326            schema: MONETARY_RELEASE_EVIDENCE_SCHEMA,
327            evidence_id: value.evidence_id,
328            evidence_kind: value.evidence_kind,
329            operation_id: value.operation_id,
330            operation_version: value.operation_version,
331            verifier_policy_digest: value.verifier_policy_digest,
332            source_binding_base64: value.source_binding_base64,
333            source_binding_digest: value.source_binding_digest,
334            source_artifacts: value.source_artifacts.into_iter().map(Into::into).collect(),
335            bundle_digest: value.bundle_digest,
336        };
337        bundle.validate()?;
338        Ok(bundle)
339    }
340
341    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ToolOutcomeError> {
342        bounded(
343            "monetary_release_evidence",
344            &self.to_persisted(),
345            MAX_MONETARY_RELEASE_EVIDENCE_BYTES,
346        )
347    }
348
349    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ToolOutcomeError> {
350        if bytes.len() > MAX_MONETARY_RELEASE_EVIDENCE_BYTES {
351            return Err(ToolOutcomeError::TooLarge {
352                field: "monetary_release_evidence",
353                actual: bytes.len(),
354                maximum: MAX_MONETARY_RELEASE_EVIDENCE_BYTES,
355            });
356        }
357        let persisted = serde_json::from_slice::<PersistedMonetaryReleaseEvidenceV1>(bytes)
358            .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
359        let bundle = Self::from_persisted(persisted)?;
360        if bundle.canonical_bytes()? != bytes {
361            return Err(ToolOutcomeError::Invalid(
362                "release_evidence.noncanonical_bytes",
363            ));
364        }
365        Ok(bundle)
366    }
367
368    pub(super) fn validate(&self) -> Result<(), ToolOutcomeError> {
369        let kinds: &[ReleaseEvidenceArtifactKindV1] = match self.evidence_kind {
370            MonetaryReleaseEvidenceKindV1::BeforeDispatchNoEffect => &[
371                ReleaseEvidenceArtifactKindV1::ParticipantQuerySnapshot,
372                ReleaseEvidenceArtifactKindV1::VerifierPolicy,
373            ],
374            MonetaryReleaseEvidenceKindV1::NotAcceptedAfterDispatch => &[
375                ReleaseEvidenceArtifactKindV1::SignedTransportStatus,
376                ReleaseEvidenceArtifactKindV1::MonotonicAttemptCheckpoint,
377                ReleaseEvidenceArtifactKindV1::VerifierPolicy,
378            ],
379            MonetaryReleaseEvidenceKindV1::ContractualZeroCharge => &[
380                ReleaseEvidenceArtifactKindV1::TerminalToolOutcome,
381                ReleaseEvidenceArtifactKindV1::TerminalPostReturnEvaluation,
382                ReleaseEvidenceArtifactKindV1::VerifierPolicy,
383            ],
384        };
385        if self.schema != MONETARY_RELEASE_EVIDENCE_SCHEMA
386            || self.source_artifacts.len() != kinds.len()
387        {
388            return Err(ToolOutcomeError::Invalid("release_evidence.shape"));
389        }
390        positive("release_evidence.operation_version", self.operation_version)?;
391        for (artifact, kind) in self.source_artifacts.iter().zip(kinds) {
392            artifact.validate()?;
393            if artifact.kind != *kind {
394                return Err(ToolOutcomeError::Binding("release_evidence.artifact_kind"));
395            }
396        }
397        let source = self.source_binding()?;
398        self.validate_source(&source)?;
399        let body = self.body();
400        let bytes = bounded(
401            "monetary_release_evidence",
402            &body,
403            MAX_MONETARY_RELEASE_EVIDENCE_BYTES,
404        )?;
405        if digest_bytes("release.bundle_digest", &bytes)? != self.bundle_digest
406            || domain_digest("chio.monetary-release-evidence.identity.v1", &body)?
407                != self.evidence_id
408        {
409            return Err(ToolOutcomeError::Binding("release_evidence.bundle"));
410        }
411        Ok(())
412    }
413
414    pub(crate) fn validate_recovery_context(
415        &self,
416        operation: &AdmissionOperationV1,
417        context: &AdmissionProjectionContext,
418    ) -> Result<(), ToolOutcomeError> {
419        self.validate()?;
420        if self.operation_id != *operation.binding().operation_id()
421            || self.operation_version != operation.version()
422            || self.operation_version != context.expected_operation_version
423        {
424            return Err(ToolOutcomeError::Binding("release_evidence.operation"));
425        }
426        match self.source_binding()? {
427            ReleaseSourceBindingV1::BeforeDispatchNoEffect(source) => {
428                validate_pre_dispatch_context(operation, context)?;
429                source.participant_manifest.validate_for(operation)?;
430                validate_source_projection(
431                    &source.verification_store_fence,
432                    &source.coordinator_lease_id,
433                    source.coordinator_lease_epoch,
434                    source.verified_at_unix_ms,
435                    context,
436                )
437            }
438            ReleaseSourceBindingV1::NotAcceptedAfterDispatch(source) => {
439                validate_projection_context(operation, context)?;
440                let commit = operation
441                    .dispatch_commit()
442                    .ok_or(ToolOutcomeError::Binding(
443                        "release_evidence.transport_dispatch_commit",
444                    ))?;
445                if source.request_id != operation.replay_key().request_id
446                    || source.request_id != context.request_id
447                    || source.request_binding_hash != *operation.binding().request_binding_hash()
448                    || source.dispatch_operation_version != commit.committed_version
449                    || source.dispatch_fence != commit.store_fence.owner_epoch
450                {
451                    return Err(ToolOutcomeError::Binding(
452                        "release_evidence.transport_operation",
453                    ));
454                }
455                validate_source_projection(
456                    &source.projection_store_fence,
457                    &source.projection_coordinator_lease_id,
458                    source.projection_coordinator_lease_epoch,
459                    source.verified_at_unix_ms,
460                    context,
461                )
462            }
463            ReleaseSourceBindingV1::ContractualZeroCharge(source) => {
464                validate_projection_context(operation, context)?;
465                if source.request_id != operation.replay_key().request_id
466                    || source.request_id != context.request_id
467                    || source.request_binding_hash != *operation.binding().request_binding_hash()
468                {
469                    return Err(ToolOutcomeError::Binding(
470                        "release_evidence.zero_charge_operation",
471                    ));
472                }
473                validate_source_projection(
474                    &source.projection_store_fence,
475                    &source.projection_coordinator_lease_id,
476                    source.projection_coordinator_lease_epoch,
477                    source.verified_at_unix_ms,
478                    context,
479                )?;
480                validate_zero_charge_records(&source, &self.source_artifacts, Some(operation))
481            }
482        }
483    }
484
485    pub fn revalidate_authority(
486        &self,
487        operation: &AdmissionOperationV1,
488        context: &AdmissionProjectionContext,
489    ) -> Result<MonetaryReleaseAuthority, ToolOutcomeError> {
490        self.validate_recovery_context(operation, context)?;
491        Err(ToolOutcomeError::ReleaseAuthorityUnavailable(
492            "persisted evidence requires a qualified co-located authority query",
493        ))
494    }
495
496    #[cfg(test)]
497    pub(super) fn with_projection_epoch_for_test(
498        &self,
499        coordinator_lease_epoch: u64,
500    ) -> Result<Self, ToolOutcomeError> {
501        let mut source = self.source_binding()?;
502        let mut source_artifacts = self.source_artifacts.clone();
503        match &mut source {
504            ReleaseSourceBindingV1::BeforeDispatchNoEffect(source) => {
505                source.coordinator_lease_epoch = coordinator_lease_epoch;
506                source.participant_manifest.coordinator_lease_epoch = coordinator_lease_epoch;
507                source_artifacts[0] = ImmutableReleaseArtifactV1::new(
508                    ReleaseEvidenceArtifactKindV1::ParticipantQuerySnapshot,
509                    source_artifacts[0].evidence_id.clone(),
510                    serde_json::to_value(&source.participant_manifest)
511                        .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
512                )?;
513                source.complete_participant_query_root = source_artifacts[0].digest.clone();
514            }
515            ReleaseSourceBindingV1::NotAcceptedAfterDispatch(source) => {
516                source.projection_coordinator_lease_epoch = coordinator_lease_epoch;
517            }
518            ReleaseSourceBindingV1::ContractualZeroCharge(source) => {
519                source.projection_coordinator_lease_epoch = coordinator_lease_epoch;
520            }
521        }
522        Self::new(
523            self.evidence_kind,
524            self.operation_id.clone(),
525            self.operation_version,
526            self.verifier_policy_digest.clone(),
527            source_artifacts,
528            source,
529        )
530    }
531
532    #[cfg(test)]
533    pub(super) fn with_request_binding_hash_for_test(
534        &self,
535        request_binding_hash: AdmissionDigest,
536    ) -> Result<Self, ToolOutcomeError> {
537        let mut source = self.source_binding()?;
538        let mut source_artifacts = self.source_artifacts.clone();
539        match &mut source {
540            ReleaseSourceBindingV1::BeforeDispatchNoEffect(source) => {
541                source.request_binding_hash = request_binding_hash.clone();
542                source.participant_manifest.request_binding_hash = request_binding_hash;
543                source_artifacts[0] = ImmutableReleaseArtifactV1::new(
544                    ReleaseEvidenceArtifactKindV1::ParticipantQuerySnapshot,
545                    source_artifacts[0].evidence_id.clone(),
546                    serde_json::to_value(&source.participant_manifest)
547                        .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?,
548                )?;
549                source.complete_participant_query_root = source_artifacts[0].digest.clone();
550            }
551            ReleaseSourceBindingV1::NotAcceptedAfterDispatch(source) => {
552                source.request_binding_hash = request_binding_hash;
553            }
554            ReleaseSourceBindingV1::ContractualZeroCharge(source) => {
555                source.request_binding_hash = request_binding_hash;
556            }
557        }
558        Self::new(
559            self.evidence_kind,
560            self.operation_id.clone(),
561            self.operation_version,
562            self.verifier_policy_digest.clone(),
563            source_artifacts,
564            source,
565        )
566    }
567
568    fn body(&self) -> ReleaseEvidenceBody<'_> {
569        ReleaseEvidenceBody {
570            schema: self.schema,
571            evidence_kind: self.evidence_kind,
572            operation_id: &self.operation_id,
573            operation_version: self.operation_version,
574            verifier_policy_digest: &self.verifier_policy_digest,
575            source_binding_base64: &self.source_binding_base64,
576            source_binding_digest: &self.source_binding_digest,
577            source_artifacts: &self.source_artifacts,
578        }
579    }
580
581    fn source_binding(&self) -> Result<ReleaseSourceBindingV1, ToolOutcomeError> {
582        let bytes = BASE64
583            .decode(&self.source_binding_base64)
584            .map_err(|_| ToolOutcomeError::Invalid("release_evidence.source_binding_base64"))?;
585        if BASE64.encode(&bytes) != self.source_binding_base64
586            || digest_bytes("release.source_binding_digest", &bytes)? != self.source_binding_digest
587        {
588            return Err(ToolOutcomeError::Binding("release_evidence.source_binding"));
589        }
590        let source = serde_json::from_slice::<ReleaseSourceBindingV1>(&bytes)
591            .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
592        if canonical(&source)? != bytes {
593            return Err(ToolOutcomeError::Invalid(
594                "release_evidence.source_binding_noncanonical",
595            ));
596        }
597        Ok(source)
598    }
599
600    fn validate_source(&self, source: &ReleaseSourceBindingV1) -> Result<(), ToolOutcomeError> {
601        match (self.evidence_kind, source) {
602            (
603                MonetaryReleaseEvidenceKindV1::BeforeDispatchNoEffect,
604                ReleaseSourceBindingV1::BeforeDispatchNoEffect(source),
605            ) => validate_pre_dispatch_source(self, source),
606            (
607                MonetaryReleaseEvidenceKindV1::NotAcceptedAfterDispatch,
608                ReleaseSourceBindingV1::NotAcceptedAfterDispatch(source),
609            ) => validate_transport_source(self, source),
610            (
611                MonetaryReleaseEvidenceKindV1::ContractualZeroCharge,
612                ReleaseSourceBindingV1::ContractualZeroCharge(source),
613            ) => validate_zero_charge_source(self, source),
614            _ => Err(ToolOutcomeError::Binding("release_evidence.source_kind")),
615        }
616    }
617}
618
619fn validate_source_projection(
620    historical_fence: &StoreMutationFence,
621    historical_coordinator_lease_id: &AdmissionIdentifier,
622    historical_coordinator_lease_epoch: u64,
623    verified_at_unix_ms: u64,
624    context: &AdmissionProjectionContext,
625) -> Result<(), ToolOutcomeError> {
626    validate_successor_fence(historical_fence, &context.store_fence)?;
627    positive("release_evidence.verified_at", verified_at_unix_ms)?;
628    if verified_at_unix_ms > context.trusted_time_unix_ms
629        || historical_coordinator_lease_epoch != context.coordinator_lease_epoch
630        || (historical_fence == &context.store_fence
631            && historical_coordinator_lease_id != &context.coordinator_lease_id)
632    {
633        return Err(ToolOutcomeError::Binding(
634            "release_evidence.projection_lineage",
635        ));
636    }
637    Ok(())
638}
639
640fn validate_pre_dispatch_source(
641    bundle: &MonetaryReleaseEvidenceV1,
642    source: &PreDispatchReleaseSourceV1,
643) -> Result<(), ToolOutcomeError> {
644    let manifest: PreDispatchParticipantManifestV1 =
645        parse_artifact_value(&bundle.source_artifacts[0])?;
646    let _: VerifierPolicyArtifactV1 = parse_artifact_value(&bundle.source_artifacts[1])?;
647    positive("release_evidence.verified_at", source.verified_at_unix_ms)?;
648    validate_store_fence(&source.verification_store_fence)?;
649    if source.operation_id != bundle.operation_id
650        || source.operation_version != bundle.operation_version
651        || source.verifier_policy_digest != bundle.verifier_policy_digest
652        || source.complete_participant_query_root != bundle.source_artifacts[0].digest
653        || source.verifier_policy_digest != bundle.source_artifacts[1].digest
654        || source.participant_manifest != manifest
655        || source.request_binding_hash != manifest.request_binding_hash
656        || source.coordinator_lease_id != manifest.coordinator_lease_id
657        || source.coordinator_lease_epoch != manifest.coordinator_lease_epoch
658        || source.verification_store_fence != manifest.verification_store_fence
659        || source.verified_at_unix_ms != manifest.verified_at_unix_ms
660    {
661        return Err(ToolOutcomeError::Binding(
662            "release_evidence.predispatch_source",
663        ));
664    }
665    Ok(())
666}
667
668fn validate_transport_source(
669    bundle: &MonetaryReleaseEvidenceV1,
670    source: &TransportReleaseSourceV1,
671) -> Result<(), ToolOutcomeError> {
672    let evidence =
673        transport::validate_artifacts(&bundle.source_artifacts, source.verified_at_unix_ms)?;
674    let signed = &evidence.signed;
675    let checkpoint = &evidence.checkpoint;
676    let attempt = &checkpoint.attempt;
677    let invocation_blob = checkpoint.phase.invocation_blob();
678    validate_store_fence(&source.projection_store_fence)?;
679    if source.operation_id != bundle.operation_id
680        || source.operation_version != bundle.operation_version
681        || source.verifier_policy_digest != bundle.verifier_policy_digest
682        || source.verifier_policy_digest != bundle.source_artifacts[2].digest
683        || signed.verifier_identity != source.verifier_identity
684        || signed.qualification_digest != source.qualification_digest
685        || evidence.cancellation_digest != source.signed_status_digest
686        || signed.cancellation.cancellation_fence != source.cancellation_fence
687        || evidence.checkpoint_digest != source.monotonic_checkpoint_digest
688        || attempt.operation_id != source.operation_id.as_str()
689        || attempt.attempt_id != source.transport_attempt_id.as_str()
690        || attempt.transport_id != source.transport_identity.as_str()
691        || attempt.transport_key_epoch != source.transport_key_epoch
692        || invocation_blob.request_digest != source.request_binding_hash.as_str()
693    {
694        return Err(ToolOutcomeError::Binding(
695            "release_evidence.transport_source",
696        ));
697    }
698    Ok(())
699}
700
701fn validate_zero_charge_source(
702    bundle: &MonetaryReleaseEvidenceV1,
703    source: &ZeroChargeReleaseSourceV1,
704) -> Result<(), ToolOutcomeError> {
705    if source.operation_id != bundle.operation_id
706        || source.operation_version != bundle.operation_version
707        || source.verifier_policy_digest != bundle.verifier_policy_digest
708        || source.verifier_policy_digest != bundle.source_artifacts[2].digest
709    {
710        return Err(ToolOutcomeError::Binding(
711            "release_evidence.zero_charge_source",
712        ));
713    }
714    validate_zero_charge_records(source, &bundle.source_artifacts, None)
715}
716
717fn validate_zero_charge_records(
718    source: &ZeroChargeReleaseSourceV1,
719    artifacts: &[ImmutableReleaseArtifactV1],
720    operation: Option<&AdmissionOperationV1>,
721) -> Result<(), ToolOutcomeError> {
722    let outcome = ToolOutcomeRecordV1::from_persisted(parse_artifact_value(&artifacts[0])?)?;
723    let evaluation =
724        PostReturnEvaluationRecordV1::from_persisted(parse_artifact_value(&artifacts[1])?)?;
725    let _: VerifierPolicyArtifactV1 = parse_artifact_value(&artifacts[2])?;
726    if let Some(operation) = operation {
727        outcome.validate_against(operation)?;
728        evaluation.validate_against(operation, &outcome)?;
729    }
730    let ResolvedToolOutcomeV1::Resolved {
731        evaluation_id,
732        pricing_verdict_digest,
733        settlement_disposition: SettlementDispositionV1::ContractualZeroCharge { currency },
734        ..
735    } = &outcome.disposition
736    else {
737        return Err(ToolOutcomeError::Binding(
738            "release_evidence.zero_charge_disposition",
739        ));
740    };
741    if outcome.operation_id != source.operation_id
742        || outcome.request_id != source.request_id
743        || outcome.outcome_id != source.tool_outcome_id
744        || outcome.version != source.outcome_version
745        || outcome.recorded_at_unix_ms > source.verified_at_unix_ms
746        || evaluation.operation_id != source.operation_id
747        || evaluation.tool_outcome_id != source.tool_outcome_id
748        || evaluation.evaluation_id != source.evaluation_id
749        || evaluation.trusted_time_unix_ms > source.verified_at_unix_ms
750        || evaluation_id != &source.evaluation_id
751        || pricing_verdict_digest != &source.pricing_verdict_digest
752        || currency != &source.currency
753    {
754        return Err(ToolOutcomeError::Binding(
755            "release_evidence.zero_charge_records",
756        ));
757    }
758    Ok(())
759}