Skip to main content

chio_kernel/admission_operation/
projection.rs

1use chio_core::capability::scope::MonetaryAmount;
2use chio_core::crypto::PublicKey;
3use chio_core::economic_continuity::{
4    EconomicAdmissionHandoffStateV1, EconomicContentV1, EconomicEffectSlotV1,
5    EconomicResourceHeadV1, EconomicResourceKeyV1, EconomicStateAnchorViewV1, EconomicStateBatchV1,
6    VerifiedEconomicStateBatchAdvance,
7};
8use chio_core::receipt::{body::ChioReceipt, decision::Decision};
9use chio_settle::channel::{
10    derive_channel_receipt_authority_digest, ChannelEscrowReservationViewV1,
11    ChannelLifecycleViewV1, SignedChannelReservationV1, SignedChannelStateV1,
12    VerifiedChannelTerminalAdvanceV1, CHANNEL_ESCROW_RESERVATION_RESOURCE_FAMILY,
13};
14
15use crate::receipt_store::{AuthorizationReceiptConsumption, PendingSettlementObservation};
16use crate::tool_outcome::{
17    SettlementDispositionV1, ToolOutcomeTerminalEvidenceV1, VerifiedPreDispatchNoEffect,
18    VerifiedTransportNotAccepted,
19};
20
21use super::*;
22
23mod participant_evidence;
24pub use participant_evidence::*;
25mod channel_terminal;
26pub use channel_terminal::*;
27mod channel_terminal_authority;
28pub use channel_terminal_authority::*;
29mod economic_cancellation;
30pub use economic_cancellation::*;
31mod factor_assignment;
32pub use factor_assignment::*;
33mod pre_dispatch_compensation;
34pub use pre_dispatch_compensation::*;
35mod outcome_unknown;
36pub use outcome_unknown::*;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum AdmissionReceiptSchema {
40    #[serde(rename = "chio.admission-receipt.v1")]
41    V1,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum AdmissionCompensationStatus {
47    NotCompensated,
48    CompensatedBeforeDispatch,
49    NotAcceptedAfterDispatchCommit,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct AdmissionReceiptMetadataV1 {
55    pub schema: AdmissionReceiptSchema,
56    pub operation_id: AdmissionOperationId,
57    pub request_id: AdmissionIdentifier,
58    pub request_namespace_digest: RequestNamespaceDigest,
59    pub request_binding_hash: AdmissionDigest,
60    pub projected_operation_version: u64,
61    pub projected_state: AdmissionOperationState,
62    pub projected_dispatch_state: AdmissionDispatchState,
63    pub trusted_time_unix_ms: u64,
64    pub coordinator_lease_id: AdmissionIdentifier,
65    pub coordinator_lease_epoch: u64,
66    pub store_fence: StoreMutationFence,
67    pub retained_dispatch_commit: Option<AdmissionDispatchCommitBindingV1>,
68    pub compensation_status: AdmissionCompensationStatus,
69    pub tool_outcome_id: Option<AdmissionDigest>,
70    pub tool_outcome_version: Option<u64>,
71}
72
73/// A receipt qualified by the kernel against the exact admission projection.
74///
75/// Raw participant and post-return evidence constructors are absent from the
76/// production API until their authority verifier ports are available.
77///
78/// ```compile_fail
79/// use chio_kernel::admission_operation::PaymentTerminalEvidence;
80/// use chio_kernel::tool_outcome::EvaluationStepResultV1;
81///
82/// let _ = PaymentTerminalEvidence::from_source_verified;
83/// let _ = EvaluationStepResultV1::pure;
84/// ```
85#[derive(Debug, Clone, Serialize)]
86#[serde(transparent)]
87pub struct VerifiedAdmissionReceipt(ChioReceipt);
88
89impl VerifiedAdmissionReceipt {
90    #[allow(dead_code)]
91    pub(crate) fn from_kernel_verified(
92        receipt: ChioReceipt,
93        expected_kernel_public_key: &PublicKey,
94        operation: &AdmissionOperationV1,
95        context: &AdmissionProjectionContext,
96        tool_outcome: &ToolOutcomeTerminalEvidenceV1,
97    ) -> Result<Self, AdmissionOperationError> {
98        Self::from_kernel_verified_terminal(
99            receipt,
100            expected_kernel_public_key,
101            &Decision::Allow,
102            operation,
103            context,
104            tool_outcome,
105        )
106    }
107
108    #[allow(dead_code)]
109    pub(crate) fn from_kernel_verified_terminal(
110        receipt: ChioReceipt,
111        expected_kernel_public_key: &PublicKey,
112        expected_decision: &Decision,
113        operation: &AdmissionOperationV1,
114        context: &AdmissionProjectionContext,
115        tool_outcome: &ToolOutcomeTerminalEvidenceV1,
116    ) -> Result<Self, AdmissionOperationError> {
117        tool_outcome
118            .validate_against(operation, context)
119            .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
120        Self::qualify(
121            receipt,
122            expected_kernel_public_key,
123            expected_decision,
124            tool_outcome.tool_server().as_str(),
125            tool_outcome.tool_name().as_str(),
126            operation.binding.action_parameter_hash(),
127            tool_outcome.resolved_output_digest(),
128            operation,
129            context,
130            AdmissionOperationState::Completed,
131            AdmissionCompensationStatus::NotCompensated,
132            Some((tool_outcome.outcome_id(), tool_outcome.outcome_version())),
133        )
134    }
135
136    #[cfg(any(test, feature = "admission-test-support"))]
137    #[allow(clippy::too_many_arguments)]
138    pub fn from_kernel_verified_for_test(
139        receipt: ChioReceipt,
140        expected_kernel_public_key: &PublicKey,
141        expected_decision: &Decision,
142        expected_tool_server: &str,
143        expected_tool_name: &str,
144        expected_parameter_hash: &AdmissionDigest,
145        expected_content_hash: &AdmissionDigest,
146        operation: &AdmissionOperationV1,
147        context: &AdmissionProjectionContext,
148        projected_state: AdmissionOperationState,
149        compensation_status: AdmissionCompensationStatus,
150        tool_outcome: Option<(&AdmissionDigest, u64)>,
151    ) -> Result<Self, AdmissionOperationError> {
152        Self::qualify(
153            receipt,
154            expected_kernel_public_key,
155            expected_decision,
156            expected_tool_server,
157            expected_tool_name,
158            expected_parameter_hash,
159            expected_content_hash,
160            operation,
161            context,
162            projected_state,
163            compensation_status,
164            tool_outcome,
165        )
166    }
167
168    #[allow(clippy::too_many_arguments)]
169    fn qualify(
170        receipt: ChioReceipt,
171        expected_kernel_public_key: &PublicKey,
172        expected_decision: &Decision,
173        expected_tool_server: &str,
174        expected_tool_name: &str,
175        expected_parameter_hash: &AdmissionDigest,
176        expected_content_hash: &AdmissionDigest,
177        operation: &AdmissionOperationV1,
178        context: &AdmissionProjectionContext,
179        projected_state: AdmissionOperationState,
180        compensation_status: AdmissionCompensationStatus,
181        tool_outcome: Option<(&AdmissionDigest, u64)>,
182    ) -> Result<Self, AdmissionOperationError> {
183        let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
184        if receipt.kernel_key != *expected_kernel_public_key
185            || !receipt.verify_signature().map_err(|_| mismatch())?
186            || receipt.decision.as_ref() != Some(expected_decision)
187            || (projected_state == AdmissionOperationState::Completed
188                && !matches!(
189                    expected_decision,
190                    Decision::Allow | Decision::Incomplete { .. }
191                ))
192            || receipt.tool_server != expected_tool_server
193            || receipt.tool_name != expected_tool_name
194            || receipt.action.parameter_hash != expected_parameter_hash.as_str()
195            || !receipt.action.verify_hash().map_err(|_| mismatch())?
196            || receipt.content_hash != expected_content_hash.as_str()
197        {
198            return Err(mismatch());
199        }
200        validate_receipt_projection(
201            &receipt,
202            operation,
203            context,
204            projected_state,
205            compensation_status,
206            tool_outcome,
207        )?;
208        Ok(Self(receipt))
209    }
210
211    #[must_use]
212    pub const fn receipt(&self) -> &ChioReceipt {
213        &self.0
214    }
215
216    pub(crate) fn validate_against(
217        &self,
218        operation: &AdmissionOperationV1,
219        context: &AdmissionProjectionContext,
220        projected_state: AdmissionOperationState,
221        compensation_status: AdmissionCompensationStatus,
222        tool_outcome: Option<(&AdmissionDigest, u64)>,
223    ) -> Result<(), AdmissionOperationError> {
224        validate_receipt_projection(
225            &self.0,
226            operation,
227            context,
228            projected_state,
229            compensation_status,
230            tool_outcome,
231        )
232    }
233}
234
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
236pub struct AdmissionProjectionCapabilities {
237    pub operation_terminal: bool,
238    pub incident_terminal: bool,
239    pub tool_outcome: bool,
240    pub payment_terminal: bool,
241    pub authorization_consumption: bool,
242    pub outcome_eligibility: bool,
243    pub observation_attempt_zero: bool,
244    pub obligation: bool,
245    pub channel_terminal: bool,
246    pub credit_exposure_terminal: bool,
247    pub economic_mutation_terminal: bool,
248}
249
250impl AdmissionProjectionCapabilities {
251    pub fn validate_for(
252        &self,
253        operation: &AdmissionOperationV1,
254        projection: &AdmissionTerminalProjection,
255    ) -> Result<(), AdmissionOperationError> {
256        let requirements = operation.binding.participant_requirements();
257        let require = |supported, capability| {
258            if supported {
259                Ok(())
260            } else {
261                Err(AdmissionOperationError::MissingProjectionCapability { capability })
262            }
263        };
264        require(self.operation_terminal, "operation_terminal")?;
265        require(
266            !requirements.credit_exposure || self.credit_exposure_terminal,
267            "credit_exposure_terminal",
268        )?;
269        match projection {
270            AdmissionTerminalProjection::Completed(_) => {
271                require(
272                    operation.binding.kind != AdmissionOperationKind::ToolDispatch
273                        || self.tool_outcome,
274                    "tool_outcome",
275                )?;
276                require(
277                    !requirements.payment || self.payment_terminal,
278                    "payment_terminal",
279                )?;
280                require(
281                    !requirements.authorization_consumption || self.authorization_consumption,
282                    "authorization_consumption",
283                )?;
284                require(
285                    !requirements.outcome_eligibility || self.outcome_eligibility,
286                    "outcome_eligibility",
287                )?;
288                require(
289                    !requirements.observation_attempt_zero || self.observation_attempt_zero,
290                    "observation_attempt_zero",
291                )?;
292                require(!requirements.obligation || self.obligation, "obligation")?;
293                require(
294                    !requirements.channel || self.channel_terminal,
295                    "channel_terminal",
296                )
297            }
298            AdmissionTerminalProjection::CompensatedBeforeDispatch { evidence, .. }
299            | AdmissionTerminalProjection::NotAcceptedAfterDispatchCommit { evidence, .. } => {
300                require(
301                    !matches!(evidence.as_ref(), AdmissionReceiptOrIncident::Incident(_))
302                        || self.incident_terminal,
303                    "incident_terminal",
304                )
305            }
306            AdmissionTerminalProjection::OutcomeUnknownAfterDispatch { .. } => {
307                require(self.incident_terminal, "incident_terminal")
308            }
309            AdmissionTerminalProjection::EconomicMutationApplied { .. }
310            | AdmissionTerminalProjection::EconomicMutationNotApplied { .. } => require(
311                self.economic_mutation_terminal,
312                "economic_mutation_terminal",
313            ),
314        }
315    }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum AdmissionProjectionRecordKind {
321    Receipt,
322    Incident,
323    ToolOutcome,
324    PaymentTerminal,
325    AuthorizationConsumption,
326    OutcomeEligibility,
327    ObservationAttemptZero,
328    Obligation,
329    ChannelTerminal,
330    ReleaseProof,
331    EconomicMutationResult,
332    MutationAudit,
333}
334
335impl AdmissionProjectionRecordKind {
336    #[must_use]
337    pub const fn as_str(self) -> &'static str {
338        match self {
339            Self::Receipt => "receipt",
340            Self::Incident => "incident",
341            Self::ToolOutcome => "tool_outcome",
342            Self::PaymentTerminal => "payment_terminal",
343            Self::AuthorizationConsumption => "authorization_consumption",
344            Self::OutcomeEligibility => "outcome_eligibility",
345            Self::ObservationAttemptZero => "observation_attempt_zero",
346            Self::Obligation => "obligation",
347            Self::ChannelTerminal => "channel_terminal",
348            Self::ReleaseProof => "release_proof",
349            Self::EconomicMutationResult => "economic_mutation_result",
350            Self::MutationAudit => "mutation_audit",
351        }
352    }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356pub enum AdmissionProjectionManifestSchema {
357    #[serde(rename = "chio.admission-projection-manifest.v1")]
358    V1,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(deny_unknown_fields)]
363pub struct AdmissionProjectionRecordCommitmentV1 {
364    kind: AdmissionProjectionRecordKind,
365    record_id: AdmissionIdentifier,
366    record_digest: AdmissionDigest,
367}
368
369impl AdmissionProjectionRecordCommitmentV1 {
370    #[must_use]
371    pub const fn kind(&self) -> AdmissionProjectionRecordKind {
372        self.kind
373    }
374
375    #[must_use]
376    pub const fn record_id(&self) -> &AdmissionIdentifier {
377        &self.record_id
378    }
379
380    #[must_use]
381    pub const fn record_digest(&self) -> &AdmissionDigest {
382        &self.record_digest
383    }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(deny_unknown_fields)]
388pub struct AdmissionProjectionManifestV1 {
389    schema: AdmissionProjectionManifestSchema,
390    projection_body_digest: AdmissionDigest,
391    records: Vec<AdmissionProjectionRecordCommitmentV1>,
392}
393
394impl AdmissionProjectionManifestV1 {
395    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, AdmissionOperationError> {
396        let manifest: Self = serde_json::from_slice(bytes)
397            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
398        manifest.validate()?;
399        if canonical_json_bytes(&manifest)
400            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?
401            != bytes
402        {
403            return Err(AdmissionOperationError::CanonicalJson(
404                "admission projection manifest is not canonical".to_string(),
405            ));
406        }
407        Ok(manifest)
408    }
409
410    pub fn canonical_bytes(&self) -> Result<Vec<u8>, AdmissionOperationError> {
411        self.validate()?;
412        canonical_json_bytes(self)
413            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))
414    }
415
416    pub fn projection_digest(&self) -> Result<AdmissionDigest, AdmissionOperationError> {
417        AdmissionDigest::try_new(
418            "terminal_projection_digest",
419            sha256_hex(&self.canonical_bytes()?),
420        )
421    }
422
423    pub fn verify_projection_body(&self, bytes: &[u8]) -> Result<(), AdmissionOperationError> {
424        let value: serde_json::Value = serde_json::from_slice(bytes)
425            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
426        if canonical_json_bytes(&value)
427            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?
428            != bytes
429            || sha256_hex(bytes) != self.projection_body_digest.as_str()
430        {
431            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
432        }
433        Ok(())
434    }
435
436    #[must_use]
437    pub const fn projection_body_digest(&self) -> &AdmissionDigest {
438        &self.projection_body_digest
439    }
440
441    #[must_use]
442    pub fn records(&self) -> &[AdmissionProjectionRecordCommitmentV1] {
443        &self.records
444    }
445
446    fn validate(&self) -> Result<(), AdmissionOperationError> {
447        if self.records.is_empty()
448            || self.records.windows(2).any(|pair| {
449                let left = (pair[0].kind.as_str(), pair[0].record_id.as_str());
450                let right = (pair[1].kind.as_str(), pair[1].record_id.as_str());
451                left >= right
452            })
453        {
454            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
455        }
456        Ok(())
457    }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct CanonicalAdmissionProjectionRecord {
462    commitment: AdmissionProjectionRecordCommitmentV1,
463    canonical_bytes: Vec<u8>,
464}
465
466impl CanonicalAdmissionProjectionRecord {
467    #[must_use]
468    pub const fn commitment(&self) -> &AdmissionProjectionRecordCommitmentV1 {
469        &self.commitment
470    }
471
472    #[must_use]
473    pub fn canonical_bytes(&self) -> &[u8] {
474        &self.canonical_bytes
475    }
476}
477
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct CanonicalAdmissionTerminalProjection {
480    projection_bytes: Vec<u8>,
481    manifest: AdmissionProjectionManifestV1,
482    manifest_bytes: Vec<u8>,
483    projection_digest: AdmissionDigest,
484    records: Vec<CanonicalAdmissionProjectionRecord>,
485}
486
487impl CanonicalAdmissionTerminalProjection {
488    #[must_use]
489    pub fn projection_bytes(&self) -> &[u8] {
490        &self.projection_bytes
491    }
492
493    #[must_use]
494    pub const fn manifest(&self) -> &AdmissionProjectionManifestV1 {
495        &self.manifest
496    }
497
498    #[must_use]
499    pub fn manifest_bytes(&self) -> &[u8] {
500        &self.manifest_bytes
501    }
502
503    #[must_use]
504    pub const fn projection_digest(&self) -> &AdmissionDigest {
505        &self.projection_digest
506    }
507
508    #[must_use]
509    pub fn records(&self) -> &[CanonicalAdmissionProjectionRecord] {
510        &self.records
511    }
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct AdmissionProjectionContext {
517    pub operation_id: AdmissionOperationId,
518    pub request_id: AdmissionIdentifier,
519    pub expected_operation_version: u64,
520    pub trusted_time_unix_ms: u64,
521    pub coordinator_lease_id: AdmissionIdentifier,
522    pub coordinator_lease_epoch: u64,
523    pub store_fence: StoreMutationFence,
524}
525
526impl AdmissionProjectionContext {
527    pub fn validate(&self) -> Result<(), AdmissionOperationError> {
528        validate_positive_ijson(
529            "expected_operation_version",
530            self.expected_operation_version,
531        )?;
532        validate_positive_ijson("trusted_time_unix_ms", self.trusted_time_unix_ms)?;
533        validate_positive_ijson("coordinator_lease_epoch", self.coordinator_lease_epoch)?;
534        AdmissionIdentifier::try_new(
535            "projection_context.coordinator_lease_id",
536            self.coordinator_lease_id.as_str().to_owned(),
537        )?;
538        validate_store_fence(&self.store_fence)
539    }
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(deny_unknown_fields)]
544pub struct AdmissionExactProjectionBindingV1 {
545    operation_id: AdmissionOperationId,
546    request_id: AdmissionIdentifier,
547    request_binding_hash: AdmissionDigest,
548    source_operation_version: u64,
549    projected_operation_version: u64,
550    projected_state: AdmissionOperationState,
551    trusted_time_unix_ms: u64,
552    coordinator_lease_id: AdmissionIdentifier,
553    coordinator_lease_epoch: u64,
554    store_fence: StoreMutationFence,
555    retained_dispatch_commit: Option<AdmissionDispatchCommitBindingV1>,
556}
557
558impl AdmissionExactProjectionBindingV1 {
559    fn from_verified(
560        operation: &AdmissionOperationV1,
561        context: &AdmissionProjectionContext,
562        projected_state: AdmissionOperationState,
563    ) -> Result<Self, AdmissionOperationError> {
564        validate_projection_context(operation, context, projected_state)?;
565        Ok(Self {
566            operation_id: operation.binding.operation_id.clone(),
567            request_id: operation.binding.request_id.clone(),
568            request_binding_hash: operation.binding.request_binding_hash().clone(),
569            source_operation_version: operation.version,
570            projected_operation_version: next_version(operation.version)?,
571            projected_state,
572            trusted_time_unix_ms: context.trusted_time_unix_ms,
573            coordinator_lease_id: context.coordinator_lease_id.clone(),
574            coordinator_lease_epoch: operation.coordinator_lease_epoch,
575            store_fence: context.store_fence.clone(),
576            retained_dispatch_commit: operation.dispatch_commit.clone(),
577        })
578    }
579
580    pub(in crate::admission_operation) fn validate_against(
581        &self,
582        operation: &AdmissionOperationV1,
583        context: &AdmissionProjectionContext,
584        projected_state: AdmissionOperationState,
585    ) -> Result<(), AdmissionOperationError> {
586        validate_projection_context(operation, context, projected_state)?;
587        if self.operation_id != operation.binding.operation_id
588            || self.request_id != operation.binding.request_id
589            || self.request_binding_hash != *operation.binding.request_binding_hash()
590            || self.source_operation_version != operation.version
591            || self.projected_operation_version != next_version(operation.version)?
592            || self.projected_state != projected_state
593            || self.trusted_time_unix_ms != context.trusted_time_unix_ms
594            || self.coordinator_lease_id != context.coordinator_lease_id
595            || self.coordinator_lease_epoch != context.coordinator_lease_epoch
596            || self.store_fence != context.store_fence
597            || self.retained_dispatch_commit != operation.dispatch_commit
598        {
599            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
600        }
601        Ok(())
602    }
603
604    #[must_use]
605    pub const fn operation_id(&self) -> &AdmissionOperationId {
606        &self.operation_id
607    }
608
609    #[must_use]
610    pub const fn request_id(&self) -> &AdmissionIdentifier {
611        &self.request_id
612    }
613
614    #[must_use]
615    pub const fn request_binding_hash(&self) -> &AdmissionDigest {
616        &self.request_binding_hash
617    }
618
619    #[must_use]
620    pub const fn source_operation_version(&self) -> u64 {
621        self.source_operation_version
622    }
623
624    #[must_use]
625    pub const fn projected_operation_version(&self) -> u64 {
626        self.projected_operation_version
627    }
628
629    #[must_use]
630    pub const fn store_fence(&self) -> &StoreMutationFence {
631        &self.store_fence
632    }
633}
634
635fn validate_projection_context(
636    operation: &AdmissionOperationV1,
637    context: &AdmissionProjectionContext,
638    projected_state: AdmissionOperationState,
639) -> Result<(), AdmissionOperationError> {
640    operation.validate()?;
641    context.validate()?;
642    if context.operation_id != operation.binding.operation_id
643        || context.request_id != operation.binding.request_id
644        || context.expected_operation_version != operation.version
645        || context.coordinator_lease_epoch != operation.coordinator_lease_epoch
646        || !projected_state.is_terminal()
647        || !is_legal_transition(
648            operation.binding.kind,
649            operation.binding.participant_requirements(),
650            operation.state,
651            projected_state,
652        )
653        || operation.dispatch_commit.as_ref().is_some_and(|commit| {
654            !projection_fence_follows(&commit.store_fence, &context.store_fence)
655        })
656    {
657        return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
658    }
659    Ok(())
660}
661
662fn projection_fence_follows(historical: &StoreMutationFence, current: &StoreMutationFence) -> bool {
663    historical.store_uuid == current.store_uuid
664        && (current.owner_epoch > historical.owner_epoch || current == historical)
665}
666
667macro_rules! admission_projection_binding {
668    ($name:ident) => {
669        #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
670        pub struct $name {
671            binding: AdmissionExactProjectionBindingV1,
672            record_id: AdmissionIdentifier,
673            record_digest: AdmissionDigest,
674        }
675
676        impl $name {
677            #[cfg(any(test, feature = "admission-test-support"))]
678            #[allow(dead_code)]
679            pub fn from_verified(
680                operation: &AdmissionOperationV1,
681                context: &AdmissionProjectionContext,
682                projected_state: AdmissionOperationState,
683                record_id: AdmissionIdentifier,
684                record_digest: AdmissionDigest,
685            ) -> Result<Self, AdmissionOperationError> {
686                Ok(Self {
687                    binding: AdmissionExactProjectionBindingV1::from_verified(
688                        operation,
689                        context,
690                        projected_state,
691                    )?,
692                    record_id,
693                    record_digest,
694                })
695            }
696
697            pub(super) fn validate_against(
698                &self,
699                operation: &AdmissionOperationV1,
700                context: &AdmissionProjectionContext,
701                projected_state: AdmissionOperationState,
702            ) -> Result<(), AdmissionOperationError> {
703                self.binding
704                    .validate_against(operation, context, projected_state)
705            }
706        }
707    };
708}
709
710admission_projection_binding!(GovernedMutationAuditEvent);
711admission_projection_binding!(AdmissionIncident);
712
713fn receipt_digest(receipt: &ChioReceipt) -> Result<AdmissionDigest, AdmissionOperationError> {
714    let bytes = canonical_json_bytes(receipt)
715        .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
716    AdmissionDigest::try_new("consumer_receipt_digest", sha256_hex(&bytes))
717}
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(rename_all = "snake_case")]
721pub enum EconomicMutationTerminalStatus {
722    Applied,
723    PermanentlyNotApplied,
724}
725
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(deny_unknown_fields)]
728pub struct GovernedEconomicMutationResultBinding {
729    binding: AdmissionExactProjectionBindingV1,
730    record_id: AdmissionIdentifier,
731    record_digest: AdmissionDigest,
732    participant_id: AdmissionIdentifier,
733    participant_key_epoch: u64,
734    resource_id: AdmissionIdentifier,
735    expected_resource_version: u64,
736    resulting_resource_version: u64,
737    expected_resource_fence: AdmissionIdentifier,
738    resulting_resource_fence: AdmissionIdentifier,
739    immutable_request_digest: AdmissionDigest,
740    signature_digest: AdmissionDigest,
741    status: EconomicMutationTerminalStatus,
742    #[serde(default)]
743    anchored_effect: bool,
744}
745
746impl GovernedEconomicMutationResultBinding {
747    #[cfg(test)]
748    #[allow(clippy::too_many_arguments, dead_code)]
749    pub(crate) fn from_verified(
750        operation: &AdmissionOperationV1,
751        context: &AdmissionProjectionContext,
752        record_id: AdmissionIdentifier,
753        record_digest: AdmissionDigest,
754        participant_id: AdmissionIdentifier,
755        participant_key_epoch: u64,
756        resource_id: AdmissionIdentifier,
757        expected_resource_version: u64,
758        resulting_resource_version: u64,
759        expected_resource_fence: AdmissionIdentifier,
760        resulting_resource_fence: AdmissionIdentifier,
761        immutable_request_digest: AdmissionDigest,
762        signature_digest: AdmissionDigest,
763        status: EconomicMutationTerminalStatus,
764    ) -> Result<Self, AdmissionOperationError> {
765        let projected_state = match status {
766            EconomicMutationTerminalStatus::Applied => {
767                AdmissionOperationState::EconomicMutationApplied
768            }
769            EconomicMutationTerminalStatus::PermanentlyNotApplied => {
770                AdmissionOperationState::EconomicMutationNotApplied
771            }
772        };
773        let binding = Self {
774            binding: AdmissionExactProjectionBindingV1::from_verified(
775                operation,
776                context,
777                projected_state,
778            )?,
779            record_id,
780            record_digest,
781            participant_id,
782            participant_key_epoch,
783            resource_id,
784            expected_resource_version,
785            resulting_resource_version,
786            expected_resource_fence,
787            resulting_resource_fence,
788            immutable_request_digest,
789            signature_digest,
790            status,
791            anchored_effect: false,
792        };
793        binding.validate_against(operation, context)?;
794        Ok(binding)
795    }
796
797    fn validate(&self) -> Result<(), AdmissionOperationError> {
798        validate_positive_ijson("participant_key_epoch", self.participant_key_epoch)
799            .map_err(|_| AdmissionOperationError::InvalidEconomicMutationBinding)?;
800        validate_positive_ijson("expected_resource_version", self.expected_resource_version)
801            .map_err(|_| AdmissionOperationError::InvalidEconomicMutationBinding)?;
802        validate_positive_ijson(
803            "resulting_resource_version",
804            self.resulting_resource_version,
805        )
806        .map_err(|_| AdmissionOperationError::InvalidEconomicMutationBinding)?;
807        Ok(())
808    }
809
810    pub(super) fn validate_against(
811        &self,
812        operation: &AdmissionOperationV1,
813        context: &AdmissionProjectionContext,
814    ) -> Result<(), AdmissionOperationError> {
815        self.validate()?;
816        let projected_state = match self.status {
817            EconomicMutationTerminalStatus::Applied => {
818                AdmissionOperationState::EconomicMutationApplied
819            }
820            EconomicMutationTerminalStatus::PermanentlyNotApplied => {
821                AdmissionOperationState::EconomicMutationNotApplied
822            }
823        };
824        self.binding
825            .validate_against(operation, context, projected_state)?;
826        if self.immutable_request_digest != *operation.binding.request_binding_hash() {
827            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
828        }
829        Ok(())
830    }
831
832    pub(in crate::admission_operation) fn validate_remote_terminal(
833        &self,
834        operation: &AdmissionOperationV1,
835        context: &AdmissionProjectionContext,
836        terminal: &AdmissionOperationV1,
837        record_id: &AdmissionIdentifier,
838    ) -> Result<bool, AdmissionOperationError> {
839        self.validate_against(operation, context)?;
840        let projected_state = match self.status {
841            EconomicMutationTerminalStatus::Applied => {
842                AdmissionOperationState::EconomicMutationApplied
843            }
844            EconomicMutationTerminalStatus::PermanentlyNotApplied => {
845                AdmissionOperationState::EconomicMutationNotApplied
846            }
847        };
848        let replay_matches = matches!(
849            terminal.terminal_replay(),
850            Some(AdmissionTerminalReplay::EconomicMutation {
851                result_id,
852                result_digest,
853                ..
854            }) if result_id == &self.record_id && result_digest == &self.record_digest
855        );
856        if terminal.state() != projected_state || record_id != &self.record_id || !replay_matches {
857            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
858        }
859        Ok(self.anchored_effect)
860    }
861
862    pub(in crate::admission_operation) fn verify_anchored_cancellation(
863        &self,
864        slot: &EconomicEffectSlotV1,
865        expected_head: &EconomicResourceHeadV1,
866        resulting_head: &EconomicResourceHeadV1,
867        checkpoint_digest: &str,
868    ) -> Result<(), AdmissionOperationError> {
869        if !self.anchored_effect
870            || self.status != EconomicMutationTerminalStatus::PermanentlyNotApplied
871            || self.participant_id.as_str() != slot.target.target_id.as_str()
872            || self.participant_key_epoch != slot.target.target_key_epoch
873            || self.resource_id.as_str() != slot.slot_id.as_str()
874            || self.expected_resource_version != expected_head.resource_version
875            || self.resulting_resource_version != resulting_head.resource_version
876            || self.expected_resource_fence.as_str()
877                != format!("effect-slot-fence:{}", expected_head.lifecycle_fence)
878            || self.resulting_resource_fence.as_str()
879                != format!("effect-slot-fence:{}", resulting_head.lifecycle_fence)
880            || self.signature_digest.as_str() != checkpoint_digest
881        {
882            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
883        }
884        Ok(())
885    }
886}
887
888#[derive(Debug, Clone, Serialize)]
889pub struct VerifiedEconomicMutationApplied(GovernedEconomicMutationResultBinding);
890
891#[derive(Debug, Clone, Serialize)]
892pub struct VerifiedEconomicMutationNotApplied(GovernedEconomicMutationResultBinding);
893
894impl VerifiedEconomicMutationApplied {
895    #[cfg(test)]
896    #[allow(dead_code)]
897    pub(crate) fn from_verified(
898        binding: GovernedEconomicMutationResultBinding,
899    ) -> Result<Self, AdmissionOperationError> {
900        binding.validate()?;
901        if binding.status != EconomicMutationTerminalStatus::Applied {
902            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
903        }
904        Ok(Self(binding))
905    }
906
907    fn validate_against(
908        &self,
909        operation: &AdmissionOperationV1,
910        context: &AdmissionProjectionContext,
911    ) -> Result<(), AdmissionOperationError> {
912        self.0.validate_against(operation, context)?;
913        if self.0.status != EconomicMutationTerminalStatus::Applied {
914            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
915        }
916        Ok(())
917    }
918}
919
920impl VerifiedEconomicMutationNotApplied {
921    #[cfg(test)]
922    #[allow(dead_code)]
923    pub(crate) fn from_verified(
924        binding: GovernedEconomicMutationResultBinding,
925    ) -> Result<Self, AdmissionOperationError> {
926        binding.validate()?;
927        if binding.status != EconomicMutationTerminalStatus::PermanentlyNotApplied {
928            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
929        }
930        Ok(Self(binding))
931    }
932
933    fn validate_against(
934        &self,
935        operation: &AdmissionOperationV1,
936        context: &AdmissionProjectionContext,
937    ) -> Result<(), AdmissionOperationError> {
938        self.0.validate_against(operation, context)?;
939        if self.0.status != EconomicMutationTerminalStatus::PermanentlyNotApplied {
940            return Err(AdmissionOperationError::InvalidEconomicMutationBinding);
941        }
942        Ok(())
943    }
944}
945
946#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
947pub struct VerifiedAuthorizationReceiptConsumption {
948    binding: AdmissionExactProjectionBindingV1,
949    consumption: AuthorizationReceiptConsumption,
950    source_receipt_digest: AdmissionDigest,
951    authorization_capability_hash: AdmissionDigest,
952    outcome_id: AdmissionDigest,
953    outcome_version: u64,
954}
955
956impl VerifiedAuthorizationReceiptConsumption {
957    #[cfg(test)]
958    #[allow(clippy::too_many_arguments, dead_code)]
959    pub(crate) fn from_source_verified(
960        operation: &AdmissionOperationV1,
961        context: &AdmissionProjectionContext,
962        receipt: &VerifiedAdmissionReceipt,
963        consumption: AuthorizationReceiptConsumption,
964        source_authorization_receipt_id: &AdmissionIdentifier,
965        source_session_id: &AdmissionIdentifier,
966        source_tool_call_id: &AdmissionIdentifier,
967        source_tenant_id: Option<&AdmissionIdentifier>,
968        source_parameter_hash: &AdmissionDigest,
969        source_receipt_digest: AdmissionDigest,
970        outcome_id: AdmissionDigest,
971        outcome_version: u64,
972    ) -> Result<Self, AdmissionOperationError> {
973        let receipt = receipt.receipt();
974        let expected_tenant = expected_receipt_tenant(operation);
975        if consumption.authorization_receipt_id != source_authorization_receipt_id.as_str()
976            || consumption.consumer_receipt_id != receipt.id
977            || consumption.request_id != operation.binding.request_id.as_str()
978            || consumption.session_id != source_session_id.as_str()
979            || consumption.tool_call_id != source_tool_call_id.as_str()
980            || consumption.tenant_id.as_deref() != source_tenant_id.map(AdmissionIdentifier::as_str)
981            || consumption.tenant_id.as_deref() != expected_tenant
982            || receipt.tenant_id.as_deref() != expected_tenant
983            || consumption.parameter_hash != source_parameter_hash.as_str()
984            || consumption.consumed_at_unix_ms != context.trusted_time_unix_ms
985        {
986            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
987        }
988        validate_positive_ijson("authorization_outcome_version", outcome_version)?;
989        operation.validate_completed_tool_outcome_attachment(&outcome_id)?;
990        Ok(Self {
991            binding: AdmissionExactProjectionBindingV1::from_verified(
992                operation,
993                context,
994                AdmissionOperationState::Completed,
995            )?,
996            consumption,
997            source_receipt_digest,
998            authorization_capability_hash: operation.binding.authorization_capability_hash.clone(),
999            outcome_id,
1000            outcome_version,
1001        })
1002    }
1003
1004    pub(super) fn validate_against(
1005        &self,
1006        operation: &AdmissionOperationV1,
1007        context: &AdmissionProjectionContext,
1008        receipt: &VerifiedAdmissionReceipt,
1009        outcome_id: &AdmissionDigest,
1010        outcome_version: u64,
1011    ) -> Result<(), AdmissionOperationError> {
1012        let receipt = receipt.receipt();
1013        self.binding
1014            .validate_against(operation, context, AdmissionOperationState::Completed)?;
1015        validate_positive_ijson("authorization_outcome_version", self.outcome_version)?;
1016        operation.validate_completed_tool_outcome_attachment(outcome_id)?;
1017        let expected_tenant = expected_receipt_tenant(operation);
1018        if AdmissionIdentifier::try_new(
1019            "authorization_receipt_id",
1020            self.consumption.authorization_receipt_id.clone(),
1021        )
1022        .is_err()
1023            || AdmissionIdentifier::try_new("session_id", self.consumption.session_id.clone())
1024                .is_err()
1025            || AdmissionIdentifier::try_new("tool_call_id", self.consumption.tool_call_id.clone())
1026                .is_err()
1027            || AdmissionDigest::try_new(
1028                "authorization_parameter_hash",
1029                self.consumption.parameter_hash.clone(),
1030            )
1031            .is_err()
1032            || self.consumption.consumer_receipt_id != receipt.id
1033            || self.consumption.request_id != operation.binding.request_id.as_str()
1034            || self.consumption.tenant_id.as_deref() != expected_tenant
1035            || receipt.tenant_id.as_deref() != expected_tenant
1036            || self.consumption.consumed_at_unix_ms != context.trusted_time_unix_ms
1037            || self.authorization_capability_hash != operation.binding.authorization_capability_hash
1038            || self.outcome_id != *outcome_id
1039            || self.outcome_version != outcome_version
1040        {
1041            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1042        }
1043        Ok(())
1044    }
1045
1046    #[must_use]
1047    pub fn consumption(&self) -> &AuthorizationReceiptConsumption {
1048        &self.consumption
1049    }
1050}
1051
1052fn expected_receipt_tenant(operation: &AdmissionOperationV1) -> Option<&str> {
1053    (operation.binding.authenticated_tenant_id.as_str() != LOCAL_SYSTEM_TENANT_ID)
1054        .then_some(operation.binding.authenticated_tenant_id.as_str())
1055}
1056
1057#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1058pub struct ObservationAttemptZero {
1059    binding: AdmissionExactProjectionBindingV1,
1060    pending: PendingSettlementObservation,
1061    consumer_receipt_id: AdmissionIdentifier,
1062    consumer_receipt_digest: AdmissionDigest,
1063    outcome_id: AdmissionDigest,
1064    outcome_version: u64,
1065}
1066
1067impl ObservationAttemptZero {
1068    pub(crate) fn from_verified(
1069        operation: &AdmissionOperationV1,
1070        context: &AdmissionProjectionContext,
1071        receipt: &VerifiedAdmissionReceipt,
1072        outcome_id: AdmissionDigest,
1073        outcome_version: u64,
1074    ) -> Result<Self, AdmissionOperationError> {
1075        let receipt = receipt.receipt();
1076        validate_positive_ijson("observation_outcome_version", outcome_version)?;
1077        operation.validate_completed_tool_outcome_attachment(&outcome_id)?;
1078        Ok(Self {
1079            binding: AdmissionExactProjectionBindingV1::from_verified(
1080                operation,
1081                context,
1082                AdmissionOperationState::Completed,
1083            )?,
1084            pending: PendingSettlementObservation {
1085                next_visible_at_ms: context.trusted_time_unix_ms,
1086            },
1087            consumer_receipt_id: AdmissionIdentifier::try_new(
1088                "consumer_receipt_id",
1089                receipt.id.clone(),
1090            )?,
1091            consumer_receipt_digest: receipt_digest(receipt)?,
1092            outcome_id,
1093            outcome_version,
1094        })
1095    }
1096
1097    #[cfg(feature = "admission-test-support")]
1098    pub fn from_verified_for_test(
1099        operation: &AdmissionOperationV1,
1100        context: &AdmissionProjectionContext,
1101        receipt: &VerifiedAdmissionReceipt,
1102        outcome_id: AdmissionDigest,
1103        outcome_version: u64,
1104    ) -> Result<Self, AdmissionOperationError> {
1105        Self::from_verified(operation, context, receipt, outcome_id, outcome_version)
1106    }
1107
1108    pub(super) fn validate_against(
1109        &self,
1110        operation: &AdmissionOperationV1,
1111        context: &AdmissionProjectionContext,
1112        receipt: &VerifiedAdmissionReceipt,
1113        outcome_id: &AdmissionDigest,
1114        outcome_version: u64,
1115    ) -> Result<(), AdmissionOperationError> {
1116        let receipt = receipt.receipt();
1117        self.binding
1118            .validate_against(operation, context, AdmissionOperationState::Completed)?;
1119        validate_positive_ijson("observation_outcome_version", self.outcome_version)?;
1120        operation.validate_completed_tool_outcome_attachment(outcome_id)?;
1121        if self.pending.next_visible_at_ms != context.trusted_time_unix_ms
1122            || self.consumer_receipt_id.as_str() != receipt.id
1123            || self.consumer_receipt_digest != receipt_digest(receipt)?
1124            || self.outcome_id != *outcome_id
1125            || self.outcome_version != outcome_version
1126        {
1127            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1128        }
1129        Ok(())
1130    }
1131
1132    #[must_use]
1133    pub const fn pending(&self) -> &PendingSettlementObservation {
1134        &self.pending
1135    }
1136
1137    #[cfg(test)]
1138    pub(super) fn with_visibility_for_test(mut self, next_visible_at_ms: u64) -> Self {
1139        self.pending.next_visible_at_ms = next_visible_at_ms;
1140        self
1141    }
1142}
1143
1144#[derive(Debug, Clone, Serialize)]
1145pub struct AdmissionCompletedProjection {
1146    pub context: AdmissionProjectionContext,
1147    pub receipt: VerifiedAdmissionReceipt,
1148    pub tool_outcome: Option<ToolOutcomeTerminalEvidenceV1>,
1149    pub payment_evidence: Option<PaymentTerminalEvidence>,
1150    pub authorization: Option<VerifiedAuthorizationReceiptConsumption>,
1151    pub eligibility: Option<OutcomeEligibilityFinalization>,
1152    pub observer_work: Option<ObservationAttemptZero>,
1153    pub obligation: Option<ObligationProjection>,
1154    pub channel_terminal: Option<VerifiedChannelTerminalProjectionV1>,
1155}
1156
1157pub(super) fn validate_completed_participant_presence(
1158    requirements: AdmissionParticipantRequirements,
1159    completed: &AdmissionCompletedProjection,
1160) -> Result<(), AdmissionOperationError> {
1161    let obligation_required = if requirements.channel {
1162        completed
1163            .channel_terminal
1164            .as_ref()
1165            .is_some_and(|channel| channel.actual_charge().units > 0)
1166    } else {
1167        requirements.obligation
1168    };
1169    if completed.payment_evidence.is_some() != requirements.payment
1170        || completed.authorization.is_some() != requirements.authorization_consumption
1171        || completed.eligibility.is_some() != requirements.outcome_eligibility
1172        || completed.observer_work.is_some() != requirements.observation_attempt_zero
1173        || completed.channel_terminal.is_some() != requirements.channel
1174        || completed.obligation.is_some() != obligation_required
1175    {
1176        return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1177    }
1178    Ok(())
1179}
1180
1181#[derive(Debug, Clone, Serialize)]
1182#[serde(tag = "kind", rename_all = "snake_case")]
1183pub enum AdmissionReceiptOrIncident {
1184    Receipt(Box<VerifiedAdmissionReceipt>),
1185    Incident(Box<AdmissionIncident>),
1186}
1187
1188#[derive(Debug, Clone, Serialize)]
1189#[serde(tag = "terminal", rename_all = "snake_case")]
1190pub enum AdmissionTerminalProjection {
1191    Completed(Box<AdmissionCompletedProjection>),
1192    CompensatedBeforeDispatch {
1193        context: AdmissionProjectionContext,
1194        proof: Box<VerifiedPreDispatchNoEffect>,
1195        evidence: Box<AdmissionReceiptOrIncident>,
1196    },
1197    NotAcceptedAfterDispatchCommit {
1198        context: AdmissionProjectionContext,
1199        proof: Box<VerifiedTransportNotAccepted>,
1200        evidence: Box<AdmissionReceiptOrIncident>,
1201    },
1202    OutcomeUnknownAfterDispatch {
1203        context: AdmissionProjectionContext,
1204        incident: Box<AdmissionIncident>,
1205    },
1206    EconomicMutationApplied {
1207        context: AdmissionProjectionContext,
1208        result: Box<VerifiedEconomicMutationApplied>,
1209        audit_event: Box<GovernedMutationAuditEvent>,
1210    },
1211    EconomicMutationNotApplied {
1212        context: AdmissionProjectionContext,
1213        result: Box<VerifiedEconomicMutationNotApplied>,
1214        audit_event: Box<GovernedMutationAuditEvent>,
1215    },
1216}
1217
1218impl AdmissionReceiptOrIncident {
1219    fn replay(
1220        &self,
1221        projection_digest: &AdmissionDigest,
1222    ) -> Result<AdmissionTerminalReplay, AdmissionOperationError> {
1223        match self {
1224            Self::Receipt(receipt) => Ok(AdmissionTerminalReplay::Receipt {
1225                receipt_id: AdmissionIdentifier::try_new(
1226                    "receipt_id",
1227                    receipt.receipt().id.clone(),
1228                )?,
1229                projection_digest: projection_digest.clone(),
1230            }),
1231            Self::Incident(incident) => Ok(AdmissionTerminalReplay::Incident {
1232                incident_id: incident.record_id.clone(),
1233                projection_digest: projection_digest.clone(),
1234            }),
1235        }
1236    }
1237
1238    fn validate_against(
1239        &self,
1240        operation: &AdmissionOperationV1,
1241        context: &AdmissionProjectionContext,
1242        projected_state: AdmissionOperationState,
1243        compensation_status: AdmissionCompensationStatus,
1244    ) -> Result<(), AdmissionOperationError> {
1245        match self {
1246            Self::Receipt(receipt) => receipt.validate_against(
1247                operation,
1248                context,
1249                projected_state,
1250                compensation_status,
1251                None,
1252            ),
1253            Self::Incident(incident) => {
1254                incident.validate_against(operation, context, projected_state)
1255            }
1256        }
1257    }
1258}
1259
1260pub(super) fn validate_receipt_projection(
1261    receipt: &ChioReceipt,
1262    operation: &AdmissionOperationV1,
1263    context: &AdmissionProjectionContext,
1264    projected_state: AdmissionOperationState,
1265    compensation_status: AdmissionCompensationStatus,
1266    tool_outcome: Option<(&AdmissionDigest, u64)>,
1267) -> Result<(), AdmissionOperationError> {
1268    let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
1269    validate_projection_context(operation, context, projected_state)?;
1270    if projected_state == AdmissionOperationState::Completed {
1271        let tool_outcome_required = operation.binding.kind == AdmissionOperationKind::ToolDispatch;
1272        if tool_outcome.is_some() != tool_outcome_required {
1273            return Err(mismatch());
1274        }
1275        if let Some((outcome_id, outcome_version)) = tool_outcome {
1276            validate_positive_ijson("receipt_tool_outcome_version", outcome_version)?;
1277            operation.validate_completed_tool_outcome_attachment(outcome_id)?;
1278        }
1279    } else if tool_outcome.is_some() {
1280        return Err(mismatch());
1281    }
1282    let expected_tenant = (operation.binding.authenticated_tenant_id.as_str()
1283        != LOCAL_SYSTEM_TENANT_ID)
1284        .then_some(operation.binding.authenticated_tenant_id.as_str());
1285    if receipt.timestamp != context.trusted_time_unix_ms / 1_000
1286        || receipt.capability_id != operation.binding.capability_id.as_str()
1287        || receipt.policy_hash != operation.binding.policy_hash.as_str()
1288        || receipt.tenant_id.as_deref() != expected_tenant
1289        || !receipt.verify_signature().map_err(|_| mismatch())?
1290    {
1291        return Err(mismatch());
1292    }
1293    let metadata = receipt
1294        .metadata
1295        .as_ref()
1296        .and_then(serde_json::Value::as_object)
1297        .and_then(|object| object.get(ADMISSION_RECEIPT_METADATA_KEY))
1298        .cloned()
1299        .ok_or_else(mismatch)
1300        .and_then(|value| {
1301            serde_json::from_value::<AdmissionReceiptMetadataV1>(value).map_err(|_| mismatch())
1302        })?;
1303    let projected_operation_version = next_version(operation.version)?;
1304    let projected_dispatch_state = dispatch_state_for(operation.binding.kind, projected_state)?;
1305    let (tool_outcome_id, tool_outcome_version) = tool_outcome
1306        .map(|(id, version)| (Some(id), Some(version)))
1307        .unwrap_or((None, None));
1308    if metadata.schema != AdmissionReceiptSchema::V1
1309        || metadata.operation_id != operation.binding.operation_id
1310        || metadata.request_id != operation.binding.request_id
1311        || metadata.request_namespace_digest != operation.binding.request_namespace_digest
1312        || metadata.request_binding_hash != *operation.binding.request_binding_hash()
1313        || metadata.projected_operation_version != projected_operation_version
1314        || metadata.projected_state != projected_state
1315        || metadata.projected_dispatch_state != projected_dispatch_state
1316        || metadata.trusted_time_unix_ms != context.trusted_time_unix_ms
1317        || metadata.coordinator_lease_id != context.coordinator_lease_id
1318        || metadata.coordinator_lease_epoch != context.coordinator_lease_epoch
1319        || metadata.store_fence != context.store_fence
1320        || metadata.retained_dispatch_commit != operation.dispatch_commit
1321        || metadata.compensation_status != compensation_status
1322        || metadata.tool_outcome_id.as_ref() != tool_outcome_id
1323        || metadata.tool_outcome_version != tool_outcome_version
1324    {
1325        return Err(mismatch());
1326    }
1327    Ok(())
1328}
1329
1330impl AdmissionTerminalProjection {
1331    #[must_use]
1332    pub fn context(&self) -> &AdmissionProjectionContext {
1333        match self {
1334            Self::Completed(projection) => &projection.context,
1335            Self::CompensatedBeforeDispatch { context, .. }
1336            | Self::NotAcceptedAfterDispatchCommit { context, .. }
1337            | Self::OutcomeUnknownAfterDispatch { context, .. }
1338            | Self::EconomicMutationApplied { context, .. }
1339            | Self::EconomicMutationNotApplied { context, .. } => context,
1340        }
1341    }
1342
1343    #[must_use]
1344    pub fn pre_dispatch_release_proof(&self) -> Option<&VerifiedPreDispatchNoEffect> {
1345        match self {
1346            Self::CompensatedBeforeDispatch { proof, .. } => Some(proof),
1347            _ => None,
1348        }
1349    }
1350
1351    #[must_use]
1352    pub fn requires_anchored_economic_commit(&self) -> bool {
1353        matches!(
1354            self,
1355            Self::Completed(projection) if projection.channel_terminal.is_some()
1356        ) || matches!(
1357            self,
1358            Self::NotAcceptedAfterDispatchCommit { proof, .. }
1359                if proof.uses_economic_effect_cancellation()
1360        ) || matches!(
1361            self,
1362            Self::EconomicMutationApplied { result, .. } if result.0.anchored_effect
1363        ) || matches!(
1364            self,
1365            Self::EconomicMutationNotApplied { result, .. } if result.0.anchored_effect
1366        )
1367    }
1368
1369    pub fn canonical_projection(
1370        &self,
1371    ) -> Result<CanonicalAdmissionTerminalProjection, AdmissionOperationError> {
1372        let projection_bytes = canonical_json_bytes(self)
1373            .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
1374        let projection_body_digest = AdmissionDigest::try_new(
1375            "terminal_projection_body_digest",
1376            sha256_hex(&projection_bytes),
1377        )?;
1378        let mut records = self.canonical_records()?;
1379        records.sort_by(|left, right| {
1380            let left = (
1381                left.commitment.kind.as_str(),
1382                left.commitment.record_id.as_str(),
1383            );
1384            let right = (
1385                right.commitment.kind.as_str(),
1386                right.commitment.record_id.as_str(),
1387            );
1388            left.cmp(&right)
1389        });
1390        let manifest = AdmissionProjectionManifestV1 {
1391            schema: AdmissionProjectionManifestSchema::V1,
1392            projection_body_digest,
1393            records: records
1394                .iter()
1395                .map(|record| record.commitment.clone())
1396                .collect(),
1397        };
1398        let manifest_bytes = manifest.canonical_bytes()?;
1399        let projection_digest =
1400            AdmissionDigest::try_new("terminal_projection_digest", sha256_hex(&manifest_bytes))?;
1401        Ok(CanonicalAdmissionTerminalProjection {
1402            projection_bytes,
1403            manifest,
1404            manifest_bytes,
1405            projection_digest,
1406            records,
1407        })
1408    }
1409
1410    pub fn canonical_bytes(&self) -> Result<Vec<u8>, AdmissionOperationError> {
1411        self.canonical_projection()
1412            .map(|projection| projection.projection_bytes)
1413    }
1414
1415    pub fn projection_digest(&self) -> Result<AdmissionDigest, AdmissionOperationError> {
1416        self.canonical_projection()
1417            .map(|projection| projection.projection_digest)
1418    }
1419
1420    fn canonical_records(
1421        &self,
1422    ) -> Result<Vec<CanonicalAdmissionProjectionRecord>, AdmissionOperationError> {
1423        let mut records = Vec::new();
1424        match self {
1425            Self::Completed(completed) => {
1426                records.push(canonical_projection_record(
1427                    AdmissionProjectionRecordKind::Receipt,
1428                    AdmissionIdentifier::try_new(
1429                        "projection_receipt_id",
1430                        completed.receipt.receipt().id.clone(),
1431                    )?,
1432                    completed.receipt.receipt(),
1433                )?);
1434                if let Some(outcome) = &completed.tool_outcome {
1435                    records.push(canonical_projection_record(
1436                        AdmissionProjectionRecordKind::ToolOutcome,
1437                        AdmissionIdentifier::try_new(
1438                            "projection_tool_outcome_id",
1439                            outcome.outcome_id().as_str().to_owned(),
1440                        )?,
1441                        outcome,
1442                    )?);
1443                }
1444                let operation_record_id = || {
1445                    AdmissionIdentifier::try_new(
1446                        "projection_operation_record_id",
1447                        completed.context.operation_id.as_str().to_owned(),
1448                    )
1449                };
1450                if let Some(payment) = &completed.payment_evidence {
1451                    records.push(canonical_projection_record(
1452                        AdmissionProjectionRecordKind::PaymentTerminal,
1453                        operation_record_id()?,
1454                        payment,
1455                    )?);
1456                }
1457                if let Some(authorization) = &completed.authorization {
1458                    records.push(canonical_projection_record(
1459                        AdmissionProjectionRecordKind::AuthorizationConsumption,
1460                        AdmissionIdentifier::try_new(
1461                            "projection_authorization_receipt_id",
1462                            authorization.consumption().authorization_receipt_id.clone(),
1463                        )?,
1464                        authorization.consumption(),
1465                    )?);
1466                }
1467                if let Some(eligibility) = &completed.eligibility {
1468                    records.push(canonical_projection_record(
1469                        AdmissionProjectionRecordKind::OutcomeEligibility,
1470                        operation_record_id()?,
1471                        eligibility,
1472                    )?);
1473                }
1474                if let Some(observer) = &completed.observer_work {
1475                    records.push(canonical_projection_record(
1476                        AdmissionProjectionRecordKind::ObservationAttemptZero,
1477                        AdmissionIdentifier::try_new(
1478                            "projection_observer_receipt_id",
1479                            completed.receipt.receipt().id.clone(),
1480                        )?,
1481                        observer.pending(),
1482                    )?);
1483                }
1484                if let Some(obligation) = &completed.obligation {
1485                    records.push(canonical_projection_record(
1486                        AdmissionProjectionRecordKind::Obligation,
1487                        operation_record_id()?,
1488                        obligation,
1489                    )?);
1490                }
1491                if let Some(channel) = &completed.channel_terminal {
1492                    records.push(canonical_projection_record(
1493                        AdmissionProjectionRecordKind::ChannelTerminal,
1494                        channel.record_id().clone(),
1495                        channel,
1496                    )?);
1497                }
1498            }
1499            Self::CompensatedBeforeDispatch {
1500                context,
1501                proof,
1502                evidence,
1503            } => {
1504                records.push(canonical_projection_record(
1505                    AdmissionProjectionRecordKind::ReleaseProof,
1506                    AdmissionIdentifier::try_new(
1507                        "projection_release_proof_id",
1508                        context.operation_id.as_str().to_owned(),
1509                    )?,
1510                    proof,
1511                )?);
1512                records.push(canonical_receipt_or_incident_record(evidence)?);
1513            }
1514            Self::NotAcceptedAfterDispatchCommit {
1515                context,
1516                proof,
1517                evidence,
1518            } => {
1519                records.push(canonical_projection_record(
1520                    AdmissionProjectionRecordKind::ReleaseProof,
1521                    AdmissionIdentifier::try_new(
1522                        "projection_release_proof_id",
1523                        context.operation_id.as_str().to_owned(),
1524                    )?,
1525                    proof,
1526                )?);
1527                records.push(canonical_receipt_or_incident_record(evidence)?);
1528            }
1529            Self::OutcomeUnknownAfterDispatch { incident, .. } => {
1530                records.push(canonical_projection_record(
1531                    AdmissionProjectionRecordKind::Incident,
1532                    incident.record_id.clone(),
1533                    incident,
1534                )?);
1535            }
1536            Self::EconomicMutationApplied {
1537                result,
1538                audit_event,
1539                ..
1540            } => {
1541                records.push(canonical_projection_record(
1542                    AdmissionProjectionRecordKind::EconomicMutationResult,
1543                    result.0.record_id.clone(),
1544                    result,
1545                )?);
1546                records.push(canonical_projection_record(
1547                    AdmissionProjectionRecordKind::MutationAudit,
1548                    audit_event.record_id.clone(),
1549                    audit_event,
1550                )?);
1551            }
1552            Self::EconomicMutationNotApplied {
1553                result,
1554                audit_event,
1555                ..
1556            } => {
1557                records.push(canonical_projection_record(
1558                    AdmissionProjectionRecordKind::EconomicMutationResult,
1559                    result.0.record_id.clone(),
1560                    result,
1561                )?);
1562                records.push(canonical_projection_record(
1563                    AdmissionProjectionRecordKind::MutationAudit,
1564                    audit_event.record_id.clone(),
1565                    audit_event,
1566                )?);
1567            }
1568        }
1569        Ok(records)
1570    }
1571}
1572
1573fn canonical_receipt_or_incident_record(
1574    evidence: &AdmissionReceiptOrIncident,
1575) -> Result<CanonicalAdmissionProjectionRecord, AdmissionOperationError> {
1576    match evidence {
1577        AdmissionReceiptOrIncident::Receipt(receipt) => canonical_projection_record(
1578            AdmissionProjectionRecordKind::Receipt,
1579            AdmissionIdentifier::try_new("projection_receipt_id", receipt.receipt().id.clone())?,
1580            receipt.receipt(),
1581        ),
1582        AdmissionReceiptOrIncident::Incident(incident) => canonical_projection_record(
1583            AdmissionProjectionRecordKind::Incident,
1584            incident.record_id.clone(),
1585            incident,
1586        ),
1587    }
1588}
1589
1590fn canonical_projection_record<T: Serialize>(
1591    kind: AdmissionProjectionRecordKind,
1592    record_id: AdmissionIdentifier,
1593    value: &T,
1594) -> Result<CanonicalAdmissionProjectionRecord, AdmissionOperationError> {
1595    let canonical_bytes = canonical_json_bytes(value)
1596        .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
1597    let record_digest =
1598        AdmissionDigest::try_new("projection_record_digest", sha256_hex(&canonical_bytes))?;
1599    Ok(CanonicalAdmissionProjectionRecord {
1600        commitment: AdmissionProjectionRecordCommitmentV1 {
1601            kind,
1602            record_id,
1603            record_digest,
1604        },
1605        canonical_bytes,
1606    })
1607}
1608
1609impl AdmissionOperationV1 {
1610    pub(super) fn validate_completed_tool_outcome_attachment(
1611        &self,
1612        outcome_id: &AdmissionDigest,
1613    ) -> Result<(), AdmissionOperationError> {
1614        if self.binding.kind != AdmissionOperationKind::ToolDispatch
1615            || self
1616                .attachments
1617                .tool_outcome_id()
1618                .is_none_or(|attached| attached.as_str() != outcome_id.as_str())
1619        {
1620            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1621        }
1622        Ok(())
1623    }
1624
1625    pub fn apply_terminal_projection(
1626        &self,
1627        projection: &AdmissionTerminalProjection,
1628        capabilities: &AdmissionProjectionCapabilities,
1629    ) -> Result<Self, AdmissionOperationError> {
1630        self.validate()?;
1631        capabilities.validate_for(self, projection)?;
1632        let context = projection.context();
1633        let projection_digest = projection.projection_digest()?;
1634        context.validate()?;
1635        if context.operation_id != self.binding.operation_id
1636            || context.request_id != self.binding.request_id
1637            || context.expected_operation_version != self.version
1638            || context.coordinator_lease_epoch != self.coordinator_lease_epoch
1639        {
1640            return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1641        }
1642        let requirements = self.binding.participant_requirements();
1643        let (next_state, replay) = match projection {
1644            AdmissionTerminalProjection::Completed(completed) => {
1645                let tool_outcome_required =
1646                    self.binding.kind == AdmissionOperationKind::ToolDispatch;
1647                if completed.tool_outcome.is_some() != tool_outcome_required {
1648                    return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1649                }
1650                validate_completed_participant_presence(requirements, completed)?;
1651                if let Some(tool_outcome) = &completed.tool_outcome {
1652                    tool_outcome
1653                        .validate_against(self, context)
1654                        .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1655                    self.validate_completed_tool_outcome_attachment(tool_outcome.outcome_id())?;
1656                }
1657                let outcome = completed
1658                    .tool_outcome
1659                    .as_ref()
1660                    .map(|value| (value.outcome_id(), value.outcome_version()));
1661                if let Some(payment) = &completed.payment_evidence {
1662                    let (outcome_id, outcome_version) = outcome
1663                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1664                    payment.validate_against(
1665                        self,
1666                        context,
1667                        &completed.receipt,
1668                        outcome_id,
1669                        outcome_version,
1670                    )?;
1671                }
1672                if let Some(eligibility) = &completed.eligibility {
1673                    let (outcome_id, outcome_version) = outcome
1674                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1675                    eligibility.validate_against(
1676                        self,
1677                        context,
1678                        &completed.receipt,
1679                        outcome_id,
1680                        outcome_version,
1681                    )?;
1682                }
1683                if let Some(obligation) = &completed.obligation {
1684                    let (outcome_id, outcome_version) = outcome
1685                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1686                    obligation.validate_against(
1687                        self,
1688                        context,
1689                        &completed.receipt,
1690                        outcome_id,
1691                        outcome_version,
1692                    )?;
1693                }
1694                if let Some(authorization) = &completed.authorization {
1695                    let (outcome_id, outcome_version) = outcome
1696                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1697                    authorization.validate_against(
1698                        self,
1699                        context,
1700                        &completed.receipt,
1701                        outcome_id,
1702                        outcome_version,
1703                    )?;
1704                }
1705                if let Some(observer_work) = &completed.observer_work {
1706                    let (outcome_id, outcome_version) = outcome
1707                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1708                    observer_work.validate_against(
1709                        self,
1710                        context,
1711                        &completed.receipt,
1712                        outcome_id,
1713                        outcome_version,
1714                    )?;
1715                }
1716                if let Some(channel) = &completed.channel_terminal {
1717                    let tool_outcome = completed
1718                        .tool_outcome
1719                        .as_ref()
1720                        .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1721                    channel.validate_against(
1722                        self,
1723                        context,
1724                        &completed.receipt,
1725                        tool_outcome,
1726                        completed.obligation.as_ref(),
1727                    )?;
1728                }
1729                completed.receipt.validate_against(
1730                    self,
1731                    context,
1732                    AdmissionOperationState::Completed,
1733                    AdmissionCompensationStatus::NotCompensated,
1734                    completed
1735                        .tool_outcome
1736                        .as_ref()
1737                        .map(|outcome| (outcome.outcome_id(), outcome.outcome_version())),
1738                )?;
1739                (
1740                    AdmissionOperationState::Completed,
1741                    AdmissionTerminalReplay::Receipt {
1742                        receipt_id: AdmissionIdentifier::try_new(
1743                            "receipt_id",
1744                            completed.receipt.receipt().id.clone(),
1745                        )?,
1746                        projection_digest: projection_digest.clone(),
1747                    },
1748                )
1749            }
1750            AdmissionTerminalProjection::CompensatedBeforeDispatch {
1751                proof, evidence, ..
1752            } => {
1753                proof
1754                    .validate_against(self, context)
1755                    .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1756                evidence.validate_against(
1757                    self,
1758                    context,
1759                    AdmissionOperationState::CompensatedBeforeDispatch,
1760                    AdmissionCompensationStatus::CompensatedBeforeDispatch,
1761                )?;
1762                (
1763                    AdmissionOperationState::CompensatedBeforeDispatch,
1764                    evidence.replay(&projection_digest)?,
1765                )
1766            }
1767            AdmissionTerminalProjection::NotAcceptedAfterDispatchCommit {
1768                proof, evidence, ..
1769            } => {
1770                proof
1771                    .validate_against(self, context)
1772                    .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1773                evidence.validate_against(
1774                    self,
1775                    context,
1776                    AdmissionOperationState::NotAcceptedAfterDispatchCommit,
1777                    AdmissionCompensationStatus::NotAcceptedAfterDispatchCommit,
1778                )?;
1779                (
1780                    AdmissionOperationState::NotAcceptedAfterDispatchCommit,
1781                    evidence.replay(&projection_digest)?,
1782                )
1783            }
1784            AdmissionTerminalProjection::OutcomeUnknownAfterDispatch { incident, .. } => {
1785                incident.validate_against(
1786                    self,
1787                    context,
1788                    AdmissionOperationState::OutcomeUnknownAfterDispatch,
1789                )?;
1790                (
1791                    AdmissionOperationState::OutcomeUnknownAfterDispatch,
1792                    AdmissionTerminalReplay::Incident {
1793                        incident_id: incident.record_id.clone(),
1794                        projection_digest: projection_digest.clone(),
1795                    },
1796                )
1797            }
1798            AdmissionTerminalProjection::EconomicMutationApplied {
1799                result,
1800                audit_event,
1801                ..
1802            } => {
1803                result.validate_against(self, context)?;
1804                audit_event.validate_against(
1805                    self,
1806                    context,
1807                    AdmissionOperationState::EconomicMutationApplied,
1808                )?;
1809                (
1810                    AdmissionOperationState::EconomicMutationApplied,
1811                    AdmissionTerminalReplay::EconomicMutation {
1812                        result_id: result.0.record_id.clone(),
1813                        result_digest: result.0.record_digest.clone(),
1814                        projection_digest: projection_digest.clone(),
1815                    },
1816                )
1817            }
1818            AdmissionTerminalProjection::EconomicMutationNotApplied {
1819                result,
1820                audit_event,
1821                ..
1822            } => {
1823                result.validate_against(self, context)?;
1824                audit_event.validate_against(
1825                    self,
1826                    context,
1827                    AdmissionOperationState::EconomicMutationNotApplied,
1828                )?;
1829                (
1830                    AdmissionOperationState::EconomicMutationNotApplied,
1831                    AdmissionTerminalReplay::EconomicMutation {
1832                        result_id: result.0.record_id.clone(),
1833                        result_digest: result.0.record_digest.clone(),
1834                        projection_digest,
1835                    },
1836                )
1837            }
1838        };
1839        if let Some(commit) = &self.dispatch_commit {
1840            if !projection_fence_follows(&commit.store_fence, &context.store_fence) {
1841                return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
1842            }
1843        }
1844        if !is_legal_transition(self.binding.kind, requirements, self.state, next_state) {
1845            return Err(AdmissionOperationError::IllegalTransition {
1846                from: self.state,
1847                to: next_state,
1848            });
1849        }
1850        validate_terminal_replay(self.binding.kind, next_state, Some(&replay))?;
1851        let mut updated = self.clone();
1852        updated.state = next_state;
1853        updated.dispatch_state = dispatch_state_for(self.binding.kind, next_state)?;
1854        updated.terminal_replay = Some(replay);
1855        updated.version = next_version(self.version)?;
1856        updated.validate()?;
1857        Ok(updated)
1858    }
1859}
1860
1861#[derive(Debug, Clone, PartialEq, Eq)]
1862pub struct AdmissionTerminal {
1863    pub operation_id: AdmissionOperationId,
1864    pub state: AdmissionOperationState,
1865    pub replay: AdmissionTerminalReplay,
1866}
1867pub(super) fn validate_terminal_replay(
1868    kind: AdmissionOperationKind,
1869    state: AdmissionOperationState,
1870    terminal_replay: Option<&AdmissionTerminalReplay>,
1871) -> Result<(), AdmissionOperationError> {
1872    let valid = match (kind, state, terminal_replay) {
1873        (_, state, None) if !state.is_terminal() => true,
1874        (
1875            AdmissionOperationKind::ToolDispatch | AdmissionOperationKind::GovernedActiveResponse,
1876            AdmissionOperationState::Completed,
1877            Some(AdmissionTerminalReplay::Receipt { .. }),
1878        ) => true,
1879        (
1880            AdmissionOperationKind::ToolDispatch | AdmissionOperationKind::GovernedActiveResponse,
1881            AdmissionOperationState::CompensatedBeforeDispatch
1882            | AdmissionOperationState::NotAcceptedAfterDispatchCommit,
1883            Some(
1884                AdmissionTerminalReplay::Receipt { .. } | AdmissionTerminalReplay::Incident { .. },
1885            ),
1886        ) => true,
1887        (
1888            AdmissionOperationKind::ToolDispatch | AdmissionOperationKind::GovernedActiveResponse,
1889            AdmissionOperationState::OutcomeUnknownAfterDispatch,
1890            Some(AdmissionTerminalReplay::Incident { .. }),
1891        ) => true,
1892        (
1893            AdmissionOperationKind::GovernedEconomicMutation,
1894            AdmissionOperationState::EconomicMutationApplied
1895            | AdmissionOperationState::EconomicMutationNotApplied,
1896            Some(AdmissionTerminalReplay::EconomicMutation { .. }),
1897        ) => true,
1898        _ => false,
1899    };
1900    if valid {
1901        Ok(())
1902    } else {
1903        Err(AdmissionOperationError::TerminalReplayMismatch)
1904    }
1905}
1906
1907#[cfg(test)]
1908#[path = "projection/channel_terminal_tests.rs"]
1909mod channel_terminal_projection_tests;
1910
1911#[cfg(test)]
1912#[path = "projection/factor_assignment_tests.rs"]
1913mod factor_assignment_projection_tests;