Skip to main content

chio_kernel/admission_operation/
remote_projection.rs

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}