1use std::collections::BTreeSet;
2
3use base64::{engine::general_purpose::STANDARD, Engine as _};
4use chio_core::capability::scope::MonetaryAmount;
5use chio_core::crypto::{Keypair, PublicKey, Signature};
6use chio_core::receipt::body::ChioReceipt;
7use chio_credit::obligation::{
8 ObligationAtomV1, ObligationDispositionRecordV1, ObligationDispositionTransitionV1,
9 ObligationDispositionV1,
10};
11use chio_settle::channel::derive_channel_receipt_authority_digest;
12use serde::{Deserialize, Serialize};
13
14use crate::receipt_store::{AuthorizationReceiptConsumption, PendingSettlementObservation};
15use crate::tool_outcome::{VerifiedPreDispatchNoEffect, VerifiedTransportNotAccepted};
16
17use super::*;
18
19const SIGNED_TERMINAL_PROJECTION_SCHEMA: &str = "chio.signed-admission-terminal-projection.v1";
20const SIGNED_TERMINAL_PROJECTION_DOMAIN: &[u8] = b"chio.signed-admission-terminal-projection.v1\0";
21pub const MAX_ADMISSION_TERMINAL_PROJECTION_BYTES: usize = 4 * 1024 * 1024;
22pub const MAX_ADMISSION_TERMINAL_MANIFEST_BYTES: usize = 256 * 1024;
23pub const MAX_ADMISSION_TERMINAL_RECORD_BYTES: usize = 1024 * 1024;
24pub const MAX_ADMISSION_TERMINAL_RECORDS: usize = 16;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct PersistedAdmissionTerminalProjectionRecordV1 {
29 pub kind: AdmissionProjectionRecordKind,
30 pub record_id: AdmissionIdentifier,
31 pub record_digest: AdmissionDigest,
32 pub canonical_json: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct AdmissionTerminalObserverProjectionV1 {
38 pub receipt_id: AdmissionIdentifier,
39 pub pending: PendingSettlementObservation,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct SignedAdmissionTerminalProjectionBodyV1 {
45 schema: String,
46 signer_key: PublicKey,
47 context: AdmissionProjectionContext,
48 source_operation: PersistedAdmissionOperationV1,
49 terminal_operation: PersistedAdmissionOperationV1,
50 projection_json: String,
51 manifest_json: String,
52 records: Vec<PersistedAdmissionTerminalProjectionRecordV1>,
53 authorization_consumption: Option<AuthorizationReceiptConsumption>,
54 observer: Option<AdmissionTerminalObserverProjectionV1>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct SignedAdmissionTerminalProjectionV1 {
60 body: SignedAdmissionTerminalProjectionBodyV1,
61 signature: Signature,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct VerifiedAdmissionTerminalProjectionRecordV1 {
66 kind: AdmissionProjectionRecordKind,
67 record_id: AdmissionIdentifier,
68 record_digest: AdmissionDigest,
69 canonical_json: Vec<u8>,
70}
71
72impl VerifiedAdmissionTerminalProjectionRecordV1 {
73 #[must_use]
74 pub const fn kind(&self) -> AdmissionProjectionRecordKind {
75 self.kind
76 }
77
78 #[must_use]
79 pub const fn record_id(&self) -> &AdmissionIdentifier {
80 &self.record_id
81 }
82
83 #[must_use]
84 pub const fn record_digest(&self) -> &AdmissionDigest {
85 &self.record_digest
86 }
87
88 #[must_use]
89 pub fn canonical_json(&self) -> &[u8] {
90 &self.canonical_json
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct VerifiedAdmissionTerminalProjectionV1 {
96 signer_key: PublicKey,
97 context: AdmissionProjectionContext,
98 source_operation: AdmissionOperationV1,
99 terminal_operation: AdmissionOperationV1,
100 projection_json: Vec<u8>,
101 manifest_json: Vec<u8>,
102 records: Vec<VerifiedAdmissionTerminalProjectionRecordV1>,
103 channel_terminal: Option<VerifiedChannelTerminalProjectionV1>,
104 anchored_transport_cancellation: bool,
105 anchored_economic_mutation: bool,
106 pre_dispatch_release_proof: Option<VerifiedPreDispatchNoEffect>,
107 authorization_consumption: Option<AuthorizationReceiptConsumption>,
108 observer: Option<AdmissionTerminalObserverProjectionV1>,
109}
110
111#[derive(Debug, Deserialize)]
112#[serde(deny_unknown_fields)]
113struct UntrustedTerminalObligationSourceV1 {
114 binding: AdmissionExactProjectionBindingV1,
115 source_authority_digest: AdmissionDigest,
116 source_record_id: AdmissionIdentifier,
117 source_record_digest: AdmissionDigest,
118 source_recorded_at_unix_ms: u64,
119 consumer_receipt_id: AdmissionIdentifier,
120 consumer_receipt_digest: AdmissionDigest,
121 outcome_id: AdmissionDigest,
122 outcome_version: u64,
123}
124
125#[derive(Debug, Deserialize)]
126#[serde(deny_unknown_fields)]
127struct UntrustedTerminalObligationProjectionV1 {
128 source: UntrustedTerminalObligationSourceV1,
129 atom: ObligationAtomV1,
130 disposition_record: ObligationDispositionRecordV1,
131}
132
133impl SignedAdmissionTerminalProjectionV1 {
134 pub fn from_verified(
135 source_operation: &AdmissionOperationV1,
136 projection: &AdmissionTerminalProjection,
137 capabilities: &AdmissionProjectionCapabilities,
138 signer: &Keypair,
139 ) -> Result<Self, AdmissionOperationError> {
140 let canonical = projection.canonical_projection()?;
141 let terminal_operation =
142 source_operation.apply_terminal_projection(projection, capabilities)?;
143 let (authorization_consumption, observer) = match projection {
144 AdmissionTerminalProjection::Completed(completed) => (
145 completed
146 .authorization
147 .as_ref()
148 .map(|authorization| authorization.consumption().clone()),
149 completed
150 .observer_work
151 .as_ref()
152 .map(|observer| {
153 Ok(AdmissionTerminalObserverProjectionV1 {
154 receipt_id: AdmissionIdentifier::try_new(
155 "observer_receipt_id",
156 completed.receipt.receipt().id.clone(),
157 )?,
158 pending: *observer.pending(),
159 })
160 })
161 .transpose()?,
162 ),
163 _ => (None, None),
164 };
165 let records = canonical
166 .records()
167 .iter()
168 .map(|record| PersistedAdmissionTerminalProjectionRecordV1 {
169 kind: record.commitment().kind(),
170 record_id: record.commitment().record_id().clone(),
171 record_digest: record.commitment().record_digest().clone(),
172 canonical_json: STANDARD.encode(record.canonical_bytes()),
173 })
174 .collect();
175 let body = SignedAdmissionTerminalProjectionBodyV1 {
176 schema: SIGNED_TERMINAL_PROJECTION_SCHEMA.to_owned(),
177 signer_key: signer.public_key(),
178 context: projection.context().clone(),
179 source_operation: source_operation.to_persisted(),
180 terminal_operation: terminal_operation.to_persisted(),
181 projection_json: STANDARD.encode(canonical.projection_bytes()),
182 manifest_json: STANDARD.encode(canonical.manifest_bytes()),
183 records,
184 authorization_consumption,
185 observer,
186 };
187 let signature = signer.sign(&signing_preimage(&body)?);
188 let envelope = Self { body, signature };
189 envelope.verify()?;
190 Ok(envelope)
191 }
192
193 pub fn verify(&self) -> Result<VerifiedAdmissionTerminalProjectionV1, AdmissionOperationError> {
194 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
195 if self.body.schema != SIGNED_TERMINAL_PROJECTION_SCHEMA
196 || !self
197 .body
198 .signer_key
199 .verify(&signing_preimage(&self.body)?, &self.signature)
200 {
201 return Err(mismatch());
202 }
203 self.body.context.validate()?;
204 let source_operation =
205 AdmissionOperationV1::from_persisted(self.body.source_operation.clone())?;
206 let terminal_operation =
207 AdmissionOperationV1::from_persisted(self.body.terminal_operation.clone())?;
208 let projection_json = decode_bounded(
209 &self.body.projection_json,
210 MAX_ADMISSION_TERMINAL_PROJECTION_BYTES,
211 )?;
212 let manifest_json = decode_bounded(
213 &self.body.manifest_json,
214 MAX_ADMISSION_TERMINAL_MANIFEST_BYTES,
215 )?;
216 let manifest = AdmissionProjectionManifestV1::from_canonical_bytes(&manifest_json)?;
217 manifest.verify_projection_body(&projection_json)?;
218 let projection_digest = manifest.projection_digest()?;
219 validate_terminal_successor(
220 &source_operation,
221 &terminal_operation,
222 &self.body.context,
223 &projection_digest,
224 )?;
225 validate_projection_body(
226 &projection_json,
227 &self.body.context,
228 terminal_operation.state(),
229 )?;
230 let records = validate_records(&self.body.records, &manifest)?;
231 validate_record_set(&source_operation, terminal_operation.state(), &records)?;
232 validate_receipt_record(
233 &records,
234 &source_operation,
235 &terminal_operation,
236 &self.body.context,
237 &self.body.signer_key,
238 )?;
239 let channel_terminal =
240 validate_channel_terminal_record(&records, &source_operation, &self.body.context)?;
241 let anchored_economic_mutation = validate_economic_mutation_result_record(
242 &records,
243 &source_operation,
244 &terminal_operation,
245 &self.body.context,
246 )?;
247 let (pre_dispatch_release_proof, anchored_transport_cancellation) =
248 validate_release_proof_record(
249 &records,
250 &source_operation,
251 terminal_operation.state(),
252 &self.body.context,
253 )?;
254 validate_sidecars(
255 &records,
256 self.body.authorization_consumption.as_ref(),
257 self.body.observer.as_ref(),
258 )?;
259 Ok(VerifiedAdmissionTerminalProjectionV1 {
260 signer_key: self.body.signer_key.clone(),
261 context: self.body.context.clone(),
262 source_operation,
263 terminal_operation,
264 projection_json,
265 manifest_json,
266 records,
267 channel_terminal,
268 anchored_transport_cancellation,
269 anchored_economic_mutation,
270 pre_dispatch_release_proof,
271 authorization_consumption: self.body.authorization_consumption.clone(),
272 observer: self.body.observer.clone(),
273 })
274 }
275}
276
277impl VerifiedAdmissionTerminalProjectionV1 {
278 #[must_use]
279 pub const fn signer_key(&self) -> &PublicKey {
280 &self.signer_key
281 }
282
283 #[must_use]
284 pub const fn pre_dispatch_release_proof(&self) -> Option<&VerifiedPreDispatchNoEffect> {
285 self.pre_dispatch_release_proof.as_ref()
286 }
287
288 #[must_use]
289 pub const fn context(&self) -> &AdmissionProjectionContext {
290 &self.context
291 }
292
293 #[must_use]
294 pub const fn source_operation(&self) -> &AdmissionOperationV1 {
295 &self.source_operation
296 }
297
298 #[must_use]
299 pub const fn terminal_operation(&self) -> &AdmissionOperationV1 {
300 &self.terminal_operation
301 }
302
303 #[must_use]
304 pub fn projection_json(&self) -> &[u8] {
305 &self.projection_json
306 }
307
308 #[must_use]
309 pub fn manifest_json(&self) -> &[u8] {
310 &self.manifest_json
311 }
312
313 #[must_use]
314 pub fn records(&self) -> &[VerifiedAdmissionTerminalProjectionRecordV1] {
315 &self.records
316 }
317
318 #[must_use]
319 pub const fn channel_terminal(&self) -> Option<&VerifiedChannelTerminalProjectionV1> {
320 self.channel_terminal.as_ref()
321 }
322
323 #[must_use]
324 pub const fn requires_anchored_economic_commit(&self) -> bool {
325 self.channel_terminal.is_some()
326 || self.anchored_transport_cancellation
327 || self.anchored_economic_mutation
328 }
329
330 #[must_use]
331 pub const fn authorization_consumption(&self) -> Option<&AuthorizationReceiptConsumption> {
332 self.authorization_consumption.as_ref()
333 }
334
335 #[must_use]
336 pub const fn observer(&self) -> Option<&AdmissionTerminalObserverProjectionV1> {
337 self.observer.as_ref()
338 }
339
340 pub fn terminal(&self) -> Result<AdmissionTerminal, AdmissionOperationError> {
341 Ok(AdmissionTerminal {
342 operation_id: self.context.operation_id.clone(),
343 state: self.terminal_operation.state(),
344 replay: self
345 .terminal_operation
346 .terminal_replay()
347 .cloned()
348 .ok_or(AdmissionOperationError::TerminalReplayMismatch)?,
349 })
350 }
351}
352
353fn signing_preimage(
354 body: &SignedAdmissionTerminalProjectionBodyV1,
355) -> Result<Vec<u8>, AdmissionOperationError> {
356 let canonical = canonical_json_bytes(body)
357 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
358 let mut preimage =
359 Vec::with_capacity(SIGNED_TERMINAL_PROJECTION_DOMAIN.len() + canonical.len());
360 preimage.extend_from_slice(SIGNED_TERMINAL_PROJECTION_DOMAIN);
361 preimage.extend_from_slice(&canonical);
362 Ok(preimage)
363}
364
365fn decode_bounded(value: &str, maximum: usize) -> Result<Vec<u8>, AdmissionOperationError> {
366 let bytes = STANDARD
367 .decode(value)
368 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
369 if bytes.is_empty() || bytes.len() > maximum {
370 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
371 }
372 Ok(bytes)
373}
374
375fn validate_terminal_successor(
376 source: &AdmissionOperationV1,
377 terminal: &AdmissionOperationV1,
378 context: &AdmissionProjectionContext,
379 projection_digest: &AdmissionDigest,
380) -> Result<(), AdmissionOperationError> {
381 let source_persisted = source.to_persisted();
382 let terminal_persisted = terminal.to_persisted();
383 if source.state().is_terminal()
384 || !terminal.state().is_terminal()
385 || context.operation_id != *source.binding().operation_id()
386 || context.request_id != source.replay_key().request_id
387 || context.expected_operation_version != source.version()
388 || context.coordinator_lease_epoch != source.coordinator_lease_epoch()
389 || source_persisted.binding != terminal_persisted.binding
390 || source_persisted.attachments != terminal_persisted.attachments
391 || source_persisted.dispatch_commit != terminal_persisted.dispatch_commit
392 || source_persisted.coordinator_lease_epoch != terminal_persisted.coordinator_lease_epoch
393 || source_persisted.last_error != terminal_persisted.last_error
394 || terminal.version() != next_version(source.version())?
395 || !is_legal_transition(
396 source.binding().kind(),
397 source.binding().participant_requirements(),
398 source.state(),
399 terminal.state(),
400 )
401 || terminal
402 .terminal_replay()
403 .is_none_or(|replay| replay.projection_digest() != projection_digest)
404 || source.dispatch_commit().is_some_and(|commit| {
405 commit.store_fence.store_uuid != context.store_fence.store_uuid
406 || (context.store_fence != commit.store_fence
407 && context.store_fence.owner_epoch <= commit.store_fence.owner_epoch)
408 })
409 {
410 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
411 }
412 Ok(())
413}
414
415fn validate_projection_body(
416 bytes: &[u8],
417 context: &AdmissionProjectionContext,
418 terminal_state: AdmissionOperationState,
419) -> Result<(), AdmissionOperationError> {
420 let value: serde_json::Value = serde_json::from_slice(bytes)
421 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
422 if canonical_json_bytes(&value)
423 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?
424 != bytes
425 {
426 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
427 }
428 let expected_terminal = match terminal_state {
429 AdmissionOperationState::Completed => "completed",
430 AdmissionOperationState::CompensatedBeforeDispatch => "compensated_before_dispatch",
431 AdmissionOperationState::NotAcceptedAfterDispatchCommit => {
432 "not_accepted_after_dispatch_commit"
433 }
434 AdmissionOperationState::OutcomeUnknownAfterDispatch => "outcome_unknown_after_dispatch",
435 AdmissionOperationState::EconomicMutationApplied => "economic_mutation_applied",
436 AdmissionOperationState::EconomicMutationNotApplied => "economic_mutation_not_applied",
437 _ => return Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
438 };
439 if value.get("terminal").and_then(serde_json::Value::as_str) != Some(expected_terminal)
440 || value.get("context")
441 != Some(
442 &serde_json::to_value(context)
443 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?,
444 )
445 {
446 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
447 }
448 Ok(())
449}
450
451fn validate_records(
452 records: &[PersistedAdmissionTerminalProjectionRecordV1],
453 manifest: &AdmissionProjectionManifestV1,
454) -> Result<Vec<VerifiedAdmissionTerminalProjectionRecordV1>, AdmissionOperationError> {
455 if records.is_empty()
456 || records.len() > MAX_ADMISSION_TERMINAL_RECORDS
457 || records.len() != manifest.records().len()
458 {
459 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
460 }
461 records
462 .iter()
463 .zip(manifest.records())
464 .map(|(record, commitment)| {
465 let canonical_json =
466 decode_bounded(&record.canonical_json, MAX_ADMISSION_TERMINAL_RECORD_BYTES)?;
467 let value: serde_json::Value = serde_json::from_slice(&canonical_json)
468 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
469 if canonical_json_bytes(&value)
470 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?
471 != canonical_json
472 || sha256_hex(&canonical_json) != record.record_digest.as_str()
473 || record.kind != commitment.kind()
474 || record.record_id != *commitment.record_id()
475 || record.record_digest != *commitment.record_digest()
476 {
477 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
478 }
479 Ok(VerifiedAdmissionTerminalProjectionRecordV1 {
480 kind: record.kind,
481 record_id: record.record_id.clone(),
482 record_digest: record.record_digest.clone(),
483 canonical_json,
484 })
485 })
486 .collect()
487}
488
489fn validate_record_set(
490 source: &AdmissionOperationV1,
491 terminal_state: AdmissionOperationState,
492 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
493) -> Result<(), AdmissionOperationError> {
494 validate_record_set_shape(
495 source.binding().kind(),
496 source.binding().participant_requirements(),
497 terminal_state,
498 records,
499 )
500}
501
502fn validate_record_set_shape(
503 operation_kind: AdmissionOperationKind,
504 requirements: AdmissionParticipantRequirements,
505 terminal_state: AdmissionOperationState,
506 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
507) -> Result<(), AdmissionOperationError> {
508 let actual = records
509 .iter()
510 .map(|record| record.kind)
511 .collect::<BTreeSet<_>>();
512 if actual.len() != records.len() {
513 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
514 }
515 let mut expected = BTreeSet::new();
516 match terminal_state {
517 AdmissionOperationState::Completed => {
518 expected.insert(AdmissionProjectionRecordKind::Receipt);
519 if operation_kind == AdmissionOperationKind::ToolDispatch {
520 expected.insert(AdmissionProjectionRecordKind::ToolOutcome);
521 }
522 for (required, kind) in [
523 (
524 requirements.payment,
525 AdmissionProjectionRecordKind::PaymentTerminal,
526 ),
527 (
528 requirements.authorization_consumption,
529 AdmissionProjectionRecordKind::AuthorizationConsumption,
530 ),
531 (
532 requirements.outcome_eligibility,
533 AdmissionProjectionRecordKind::OutcomeEligibility,
534 ),
535 (
536 requirements.observation_attempt_zero,
537 AdmissionProjectionRecordKind::ObservationAttemptZero,
538 ),
539 ] {
540 if required {
541 expected.insert(kind);
542 }
543 }
544 if requirements.channel {
545 expected.insert(AdmissionProjectionRecordKind::ChannelTerminal);
546 if channel_terminal_charge(records)?.units > 0 {
547 expected.insert(AdmissionProjectionRecordKind::Obligation);
548 }
549 } else if requirements.obligation {
550 expected.insert(AdmissionProjectionRecordKind::Obligation);
551 }
552 }
553 AdmissionOperationState::CompensatedBeforeDispatch
554 | AdmissionOperationState::NotAcceptedAfterDispatchCommit => {
555 expected.insert(AdmissionProjectionRecordKind::ReleaseProof);
556 let evidence_count =
557 usize::from(actual.contains(&AdmissionProjectionRecordKind::Receipt))
558 + usize::from(actual.contains(&AdmissionProjectionRecordKind::Incident));
559 if evidence_count != 1 {
560 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
561 }
562 expected.insert(
563 if actual.contains(&AdmissionProjectionRecordKind::Receipt) {
564 AdmissionProjectionRecordKind::Receipt
565 } else {
566 AdmissionProjectionRecordKind::Incident
567 },
568 );
569 }
570 AdmissionOperationState::OutcomeUnknownAfterDispatch => {
571 expected.insert(AdmissionProjectionRecordKind::Incident);
572 }
573 AdmissionOperationState::EconomicMutationApplied
574 | AdmissionOperationState::EconomicMutationNotApplied => {
575 expected.insert(AdmissionProjectionRecordKind::EconomicMutationResult);
576 expected.insert(AdmissionProjectionRecordKind::MutationAudit);
577 }
578 _ => return Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
579 }
580 if actual != expected {
581 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
582 }
583 Ok(())
584}
585
586fn validate_release_proof_record(
587 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
588 source: &AdmissionOperationV1,
589 terminal_state: AdmissionOperationState,
590 context: &AdmissionProjectionContext,
591) -> Result<(Option<VerifiedPreDispatchNoEffect>, bool), AdmissionOperationError> {
592 let record = records
593 .iter()
594 .find(|record| record.kind == AdmissionProjectionRecordKind::ReleaseProof);
595 match (terminal_state, record) {
596 (AdmissionOperationState::CompensatedBeforeDispatch, Some(record)) => {
597 VerifiedPreDispatchNoEffect::from_canonical_record_verified(
598 record.canonical_json(),
599 source,
600 context,
601 )
602 .map(|proof| (Some(proof), false))
603 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)
604 }
605 (AdmissionOperationState::NotAcceptedAfterDispatchCommit, Some(record)) => {
606 VerifiedTransportNotAccepted::from_canonical_record_verified(
607 record.canonical_json(),
608 source,
609 context,
610 )
611 .map(|proof| (None, proof.uses_economic_effect_cancellation()))
612 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)
613 }
614 (
615 AdmissionOperationState::Completed
616 | AdmissionOperationState::OutcomeUnknownAfterDispatch
617 | AdmissionOperationState::EconomicMutationApplied
618 | AdmissionOperationState::EconomicMutationNotApplied,
619 None,
620 ) => Ok((None, false)),
621 _ => Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
622 }
623}
624
625fn validate_economic_mutation_result_record(
626 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
627 source: &AdmissionOperationV1,
628 terminal: &AdmissionOperationV1,
629 context: &AdmissionProjectionContext,
630) -> Result<bool, AdmissionOperationError> {
631 if !matches!(
632 terminal.state(),
633 AdmissionOperationState::EconomicMutationApplied
634 | AdmissionOperationState::EconomicMutationNotApplied
635 ) {
636 return Ok(false);
637 }
638 let record = records
639 .iter()
640 .find(|record| record.kind == AdmissionProjectionRecordKind::EconomicMutationResult)
641 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
642 let result: GovernedEconomicMutationResultBinding =
643 serde_json::from_slice(record.canonical_json())
644 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
645 result
646 .validate_remote_terminal(source, context, terminal, record.record_id())
647 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)
648}
649
650fn channel_terminal_charge(
651 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
652) -> Result<MonetaryAmount, AdmissionOperationError> {
653 let record = records
654 .iter()
655 .find(|record| record.kind == AdmissionProjectionRecordKind::ChannelTerminal)
656 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
657 let value: serde_json::Value = serde_json::from_slice(record.canonical_json())
658 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
659 let charge = value
660 .get("actual_charge")
661 .cloned()
662 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)
663 .and_then(|value| {
664 serde_json::from_value::<MonetaryAmount>(value)
665 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)
666 })?;
667 if charge.units > ((1_u64 << 53) - 1)
668 || charge.currency.len() != 3
669 || !charge
670 .currency
671 .bytes()
672 .all(|byte| byte.is_ascii_uppercase())
673 {
674 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
675 }
676 Ok(charge)
677}
678
679fn validate_channel_terminal_record(
680 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
681 source: &AdmissionOperationV1,
682 context: &AdmissionProjectionContext,
683) -> Result<Option<VerifiedChannelTerminalProjectionV1>, AdmissionOperationError> {
684 let Some(record) = records
685 .iter()
686 .find(|record| record.kind == AdmissionProjectionRecordKind::ChannelTerminal)
687 else {
688 return Ok(None);
689 };
690 let channel = VerifiedChannelTerminalProjectionV1::from_canonical_record_verified(
691 record.canonical_json(),
692 source,
693 context,
694 )?;
695 if channel.record_id() != record.record_id() {
696 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
697 }
698 let receipt_record = records
699 .iter()
700 .find(|record| record.kind == AdmissionProjectionRecordKind::Receipt)
701 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
702 let receipt: ChioReceipt = serde_json::from_slice(receipt_record.canonical_json())
703 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
704 let receipt_digest = AdmissionDigest::try_new(
705 "channel_terminal_receipt_digest",
706 sha256_hex(
707 &canonical_json_bytes(&receipt)
708 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?,
709 ),
710 )?;
711 let expected_authority = AdmissionDigest::try_new(
712 "channel_terminal_receipt_authority_digest",
713 derive_channel_receipt_authority_digest(&receipt.kernel_key)
714 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?,
715 )?;
716 let financial = receipt
717 .financial_metadata()
718 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
719 let metadata = receipt
720 .channel_metadata()
721 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
722 let tool_record = records
723 .iter()
724 .find(|record| record.kind == AdmissionProjectionRecordKind::ToolOutcome)
725 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
726 let Some(chio_core::economic_continuity::EconomicEffectTerminalV1::Completed {
727 result_id,
728 result_digest,
729 result,
730 }) = channel.completed_effect_slot().terminal.as_ref()
731 else {
732 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
733 };
734 let chio_core::economic_continuity::EconomicContentV1::Inline { value } = result else {
735 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
736 };
737 let result_bytes = canonical_json_bytes(value)
738 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
739 let expected_result_digest = result
740 .digest()
741 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
742 if channel.receipt_id().as_str() != receipt.id
743 || channel.receipt_digest() != &receipt_digest
744 || channel.receipt_authority_digest() != &expected_authority
745 || channel.actual_charge().units != financial.cost_charged
746 || channel.actual_charge().currency != financial.currency
747 || metadata.channel_id != channel.signed_reservation().body.channel_id
748 || metadata.open_digest != channel.signed_reservation().body.open_digest
749 || metadata.reservation_id != channel.signed_reservation().body.reservation_id
750 || metadata.reservation_digest != channel.reservation_digest().as_str()
751 || metadata.sequence != channel.signed_reservation().body.next_sequence
752 || result_id != tool_record.record_id().as_str()
753 || result_digest != &expected_result_digest
754 || result_bytes != tool_record.canonical_json()
755 {
756 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
757 }
758 validate_channel_obligation_record(records, source, context, &channel, &receipt, tool_record)?;
759 Ok(Some(channel))
760}
761
762fn validate_channel_obligation_record(
763 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
764 source: &AdmissionOperationV1,
765 context: &AdmissionProjectionContext,
766 channel: &VerifiedChannelTerminalProjectionV1,
767 receipt: &ChioReceipt,
768 tool_record: &VerifiedAdmissionTerminalProjectionRecordV1,
769) -> Result<(), AdmissionOperationError> {
770 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
771 let obligation_record = records
772 .iter()
773 .find(|record| record.kind == AdmissionProjectionRecordKind::Obligation);
774 if channel.actual_charge().units == 0 {
775 if obligation_record.is_some()
776 || channel.obligation_atom_id().is_some()
777 || channel.obligation_atom_digest().is_some()
778 {
779 return Err(mismatch());
780 }
781 return Ok(());
782 }
783 let obligation_record = obligation_record.ok_or_else(mismatch)?;
784 let obligation: UntrustedTerminalObligationProjectionV1 =
785 serde_json::from_slice(obligation_record.canonical_json()).map_err(|_| mismatch())?;
786 obligation.source.binding.validate_against(
787 source,
788 context,
789 AdmissionOperationState::Completed,
790 )?;
791 obligation.atom.validate().map_err(|_| mismatch())?;
792 obligation
793 .disposition_record
794 .validate_against(&obligation.atom)
795 .map_err(|_| mismatch())?;
796 let atom_digest = obligation.atom.digest().map_err(|_| mismatch())?;
797 let proposal_intent_digest = channel
798 .signed_reservation()
799 .body
800 .proposal_digest()
801 .map_err(|_| mismatch())?;
802 let tool_value: serde_json::Value =
803 serde_json::from_slice(tool_record.canonical_json()).map_err(|_| mismatch())?;
804 let outcome_version = tool_value
805 .get("outcome_version")
806 .and_then(serde_json::Value::as_u64)
807 .ok_or_else(mismatch)?;
808 validate_positive_ijson("channel_terminal_outcome_version", outcome_version)?;
809 let disposition_matches = matches!(
810 obligation.disposition_record.disposition(),
811 ObligationDispositionV1::Channelized {
812 channel_id,
813 reservation_id,
814 } if channel_id == &channel.signed_reservation().body.channel_id
815 && reservation_id == &channel.signed_reservation().body.reservation_id
816 );
817 let transition_matches = matches!(
818 obligation.disposition_record.last_transition(),
819 ObligationDispositionTransitionV1::ReserveChannel {
820 channel_id,
821 reservation_id,
822 authority_digest,
823 } if channel_id == &channel.signed_reservation().body.channel_id
824 && reservation_id == &channel.signed_reservation().body.reservation_id
825 && authority_digest == channel.reservation_digest().as_str()
826 );
827 if obligation_record.record_id().as_str() != source.binding().operation_id().as_str()
828 || channel
829 .obligation_atom_id()
830 .map(AdmissionIdentifier::as_str)
831 != Some(obligation.atom.obligation_id())
832 || channel
833 .obligation_atom_digest()
834 .map(AdmissionDigest::as_str)
835 != Some(atom_digest.as_str())
836 || obligation.atom.amount() != channel.actual_charge()
837 || obligation.atom.economic_intent_digest() != proposal_intent_digest.as_str()
838 || obligation.atom.source_receipt_id() != receipt.id.as_str()
839 || obligation.atom.source_receipt_id() != channel.receipt_id().as_str()
840 || obligation.atom.source_receipt_digest() != channel.receipt_digest().as_str()
841 || obligation.atom.pre_action_authority_digest() != channel.reservation_digest().as_str()
842 || obligation.atom.created_at_unix_ms() > context.trusted_time_unix_ms
843 || obligation.source.source_authority_digest != *channel.reservation_digest()
844 || obligation.source.source_record_id.as_str() != obligation.atom.obligation_id()
845 || obligation.source.source_record_digest.as_str() != atom_digest.as_str()
846 || obligation.source.source_recorded_at_unix_ms != obligation.atom.created_at_unix_ms()
847 || obligation.source.consumer_receipt_id != *channel.receipt_id()
848 || obligation.source.consumer_receipt_digest != *channel.receipt_digest()
849 || obligation.source.outcome_id.as_str() != tool_record.record_id().as_str()
850 || obligation.source.outcome_version != outcome_version
851 || obligation.disposition_record.version() != 2
852 || obligation.disposition_record.lifecycle_fence() != 2
853 || !disposition_matches
854 || !transition_matches
855 {
856 return Err(mismatch());
857 }
858 Ok(())
859}
860
861fn validate_receipt_record(
862 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
863 source: &AdmissionOperationV1,
864 terminal: &AdmissionOperationV1,
865 context: &AdmissionProjectionContext,
866 signer_key: &PublicKey,
867) -> Result<(), AdmissionOperationError> {
868 let Some(record) = records
869 .iter()
870 .find(|record| record.kind == AdmissionProjectionRecordKind::Receipt)
871 else {
872 return Ok(());
873 };
874 let receipt: ChioReceipt = serde_json::from_slice(&record.canonical_json)
875 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?;
876 if receipt.id != record.record_id.as_str() || receipt.kernel_key != *signer_key {
877 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
878 }
879 let metadata = receipt
880 .metadata
881 .as_ref()
882 .and_then(serde_json::Value::as_object)
883 .and_then(|object| object.get(ADMISSION_RECEIPT_METADATA_KEY))
884 .cloned()
885 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)
886 .and_then(|value| {
887 serde_json::from_value::<AdmissionReceiptMetadataV1>(value)
888 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)
889 })?;
890 let outcome = metadata
891 .tool_outcome_id
892 .as_ref()
893 .zip(metadata.tool_outcome_version);
894 let compensation = match terminal.state() {
895 AdmissionOperationState::Completed => AdmissionCompensationStatus::NotCompensated,
896 AdmissionOperationState::CompensatedBeforeDispatch => {
897 AdmissionCompensationStatus::CompensatedBeforeDispatch
898 }
899 AdmissionOperationState::NotAcceptedAfterDispatchCommit => {
900 AdmissionCompensationStatus::NotAcceptedAfterDispatchCommit
901 }
902 _ => return Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
903 };
904 validate_receipt_projection(
905 &receipt,
906 source,
907 context,
908 terminal.state(),
909 compensation,
910 outcome,
911 )
912}
913
914fn validate_sidecars(
915 records: &[VerifiedAdmissionTerminalProjectionRecordV1],
916 authorization: Option<&AuthorizationReceiptConsumption>,
917 observer: Option<&AdmissionTerminalObserverProjectionV1>,
918) -> Result<(), AdmissionOperationError> {
919 let authorization_record = records
920 .iter()
921 .find(|record| record.kind == AdmissionProjectionRecordKind::AuthorizationConsumption);
922 match (authorization_record, authorization) {
923 (Some(record), Some(authorization))
924 if record.record_id.as_str() == authorization.authorization_receipt_id
925 && record.canonical_json
926 == canonical_json_bytes(authorization).map_err(|error| {
927 AdmissionOperationError::CanonicalJson(error.to_string())
928 })? => {}
929 (None, None) => {}
930 _ => return Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
931 }
932 let observer_record = records
933 .iter()
934 .find(|record| record.kind == AdmissionProjectionRecordKind::ObservationAttemptZero);
935 match (observer_record, observer) {
936 (Some(record), Some(observer))
937 if record.record_id == observer.receipt_id
938 && record.canonical_json
939 == canonical_json_bytes(&observer.pending).map_err(|error| {
940 AdmissionOperationError::CanonicalJson(error.to_string())
941 })? => {}
942 (None, None) => {}
943 _ => return Err(AdmissionOperationError::TerminalProjectionBindingMismatch),
944 }
945 Ok(())
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 fn record(
953 kind: AdmissionProjectionRecordKind,
954 value: serde_json::Value,
955 ) -> Result<VerifiedAdmissionTerminalProjectionRecordV1, AdmissionOperationError> {
956 let canonical_json = canonical_json_bytes(&value)
957 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
958 Ok(VerifiedAdmissionTerminalProjectionRecordV1 {
959 kind,
960 record_id: AdmissionIdentifier::try_new("record_id", kind.as_str().to_owned())?,
961 record_digest: AdmissionDigest::try_new("record_digest", sha256_hex(&canonical_json))?,
962 canonical_json,
963 })
964 }
965
966 fn channel_records(
967 units: u64,
968 ) -> Result<Vec<VerifiedAdmissionTerminalProjectionRecordV1>, AdmissionOperationError> {
969 Ok(vec![
970 record(
971 AdmissionProjectionRecordKind::Receipt,
972 serde_json::json!({}),
973 )?,
974 record(
975 AdmissionProjectionRecordKind::ToolOutcome,
976 serde_json::json!({}),
977 )?,
978 record(
979 AdmissionProjectionRecordKind::ChannelTerminal,
980 serde_json::json!({
981 "actual_charge": { "currency": "USD", "units": units }
982 }),
983 )?,
984 ])
985 }
986
987 #[test]
988 fn channel_record_set_requires_charge_dependent_obligation(
989 ) -> Result<(), AdmissionOperationError> {
990 let requirements = AdmissionParticipantRequirements {
991 broker_attempt: true,
992 budget_capture: true,
993 obligation: true,
994 channel: true,
995 ..AdmissionParticipantRequirements::NONE
996 };
997 let validate = |records: &[VerifiedAdmissionTerminalProjectionRecordV1]| {
998 validate_record_set_shape(
999 AdmissionOperationKind::ToolDispatch,
1000 requirements,
1001 AdmissionOperationState::Completed,
1002 records,
1003 )
1004 };
1005
1006 let mut positive = channel_records(7)?;
1007 assert_eq!(
1008 validate(&positive),
1009 Err(AdmissionOperationError::TerminalProjectionBindingMismatch)
1010 );
1011 positive.push(record(
1012 AdmissionProjectionRecordKind::Obligation,
1013 serde_json::json!({}),
1014 )?);
1015 validate(&positive)?;
1016
1017 let mut zero = channel_records(0)?;
1018 validate(&zero)?;
1019 zero.push(record(
1020 AdmissionProjectionRecordKind::Obligation,
1021 serde_json::json!({}),
1022 )?);
1023 assert_eq!(
1024 validate(&zero),
1025 Err(AdmissionOperationError::TerminalProjectionBindingMismatch)
1026 );
1027 Ok(())
1028 }
1029
1030 #[test]
1031 fn channel_record_set_rejects_missing_duplicate_and_malformed_terminal_records(
1032 ) -> Result<(), AdmissionOperationError> {
1033 let requirements = AdmissionParticipantRequirements {
1034 broker_attempt: true,
1035 budget_capture: true,
1036 obligation: true,
1037 channel: true,
1038 ..AdmissionParticipantRequirements::NONE
1039 };
1040 let validate = |records: &[VerifiedAdmissionTerminalProjectionRecordV1]| {
1041 validate_record_set_shape(
1042 AdmissionOperationKind::ToolDispatch,
1043 requirements,
1044 AdmissionOperationState::Completed,
1045 records,
1046 )
1047 };
1048
1049 let mut missing = channel_records(0)?;
1050 missing.retain(|record| record.kind != AdmissionProjectionRecordKind::ChannelTerminal);
1051 assert!(validate(&missing).is_err());
1052
1053 let mut duplicate = channel_records(0)?;
1054 duplicate.push(record(
1055 AdmissionProjectionRecordKind::ChannelTerminal,
1056 serde_json::json!({
1057 "actual_charge": { "currency": "USD", "units": 0 }
1058 }),
1059 )?);
1060 assert!(validate(&duplicate).is_err());
1061
1062 let mut malformed = channel_records(0)?;
1063 let channel_record = malformed
1064 .iter_mut()
1065 .find(|record| record.kind == AdmissionProjectionRecordKind::ChannelTerminal)
1066 .ok_or(AdmissionOperationError::TerminalProjectionBindingMismatch)?;
1067 channel_record.canonical_json = canonical_json_bytes(&serde_json::json!({
1068 "actual_charge": { "currency": "usd", "units": 0 }
1069 }))
1070 .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
1071 assert!(validate(&malformed).is_err());
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn non_channel_record_set_preserves_legacy_obligation_shape(
1077 ) -> Result<(), AdmissionOperationError> {
1078 let requirements = AdmissionParticipantRequirements {
1079 broker_attempt: true,
1080 budget_capture: true,
1081 obligation: true,
1082 ..AdmissionParticipantRequirements::NONE
1083 };
1084 let mut records = vec![
1085 record(
1086 AdmissionProjectionRecordKind::Receipt,
1087 serde_json::json!({}),
1088 )?,
1089 record(
1090 AdmissionProjectionRecordKind::ToolOutcome,
1091 serde_json::json!({}),
1092 )?,
1093 record(
1094 AdmissionProjectionRecordKind::Obligation,
1095 serde_json::json!({}),
1096 )?,
1097 ];
1098 let validate = |records: &[VerifiedAdmissionTerminalProjectionRecordV1]| {
1099 validate_record_set_shape(
1100 AdmissionOperationKind::ToolDispatch,
1101 requirements,
1102 AdmissionOperationState::Completed,
1103 records,
1104 )
1105 };
1106 validate(&records)?;
1107
1108 records.push(record(
1109 AdmissionProjectionRecordKind::ChannelTerminal,
1110 serde_json::json!({
1111 "actual_charge": { "currency": "USD", "units": 0 }
1112 }),
1113 )?);
1114 assert_eq!(
1115 validate(&records),
1116 Err(AdmissionOperationError::TerminalProjectionBindingMismatch)
1117 );
1118 Ok(())
1119 }
1120}