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#[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;