Skip to main content

chio_kernel/kernel/
settlement_observer.rs

1//! Settlement observer slot wired into the kernel evaluator.
2//!
3//! Plugs `chio-settle::SettlementHook` into the kernel's post-dispatch
4//! observer surface. A hook produces a typed outcome. Durable retry and
5//! dead-letter routing is provided by the paired kernel observer runtime.
6//!
7//! The kernel field [`ChioKernel::settlement_observer`] holds an
8//! optional handle; deployments that do not wire a settlement runtime
9//! see byte-identical receipts (an integration test enforces this
10//! invariant explicitly).
11
12use std::sync::Arc;
13
14use chio_core::crypto::PublicKey;
15use chio_core::receipt::{body::ChioReceipt, economics::ChannelReceiptMetadataV1};
16use chio_settle::{
17    SettlementFailureClass, SettlementFailureCode, SettlementFailureReason, SettlementHook,
18    SettlementHookError, SettlementIdempotencyKey, SettlementObservation, SettlementOutcome,
19    SettlementSkipReason,
20};
21
22/// Schema string emitted on the wire for settlement-observer status frames.
23/// Public so external observers can pin against the same identifier the
24/// kernel records.
25#[allow(dead_code)]
26pub const SETTLEMENT_OBSERVER_STATUS_SCHEMA: &str = "chio.settle.observer-status.v1";
27
28/// Status the kernel records for each settlement observer invocation.
29///
30/// Settlement runs post-dispatch: regardless of which variant lands,
31/// the receipt has already been signed and persisted. The variants
32/// document only what the observer slot did with the hook's return,
33/// not whether the receipt committed (it always committed).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum SettlementObserverStatus {
36    /// No settlement hook is registered on this kernel; the observation
37    /// was not produced.
38    NotRegistered,
39    /// The receipt requires no settlement work.
40    Skipped { reason: SettlementSkipReason },
41    /// The hook accepted the observation and returned an outcome
42    /// classification. The downstream lifecycle is then driven by the
43    /// retry policy and dead-letter machinery.
44    Observed { outcome: SettlementOutcome },
45    /// Observation construction or the hook returned a typed failure.
46    HookFailed {
47        class: SettlementFailureClass,
48        reason: SettlementFailureReason,
49    },
50}
51
52impl SettlementObserverStatus {
53    #[must_use]
54    pub const fn skipped(reason: SettlementSkipReason) -> Self {
55        Self::Skipped { reason }
56    }
57
58    #[must_use]
59    pub fn hook_failed(err: &SettlementHookError) -> Self {
60        let (class, reason) = err.classification();
61        Self::HookFailed { class, reason }
62    }
63}
64
65/// Result of validating and interpreting a finalized receipt for settlement.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum SettlementObservationBuild {
68    /// A valid positive-charge receipt produced an observation.
69    Observation(SettlementObservation),
70    /// The receipt legitimately requires no settlement work.
71    Skipped(SettlementSkipReason),
72    /// The receipt failed integrity or economic-metadata validation.
73    Permanent(SettlementFailureReason),
74}
75
76fn permanent(code: SettlementFailureCode, detail: impl AsRef<[u8]>) -> SettlementObservationBuild {
77    SettlementObservationBuild::Permanent(SettlementFailureReason::from_detail(code, detail))
78}
79
80#[must_use]
81pub fn build_observation(
82    receipt: &ChioReceipt,
83    trusted_kernel_keys: &[PublicKey],
84) -> SettlementObservationBuild {
85    match receipt.verify_signature() {
86        Ok(true) => {}
87        Ok(false) => {
88            return permanent(
89                SettlementFailureCode::InvalidReceiptSignature,
90                "receipt signature did not verify",
91            );
92        }
93        Err(error) => {
94            return permanent(
95                SettlementFailureCode::InvalidReceiptSignature,
96                error.to_string(),
97            );
98        }
99    }
100    match receipt.action.verify_hash() {
101        Ok(true) => {}
102        Ok(false) => {
103            return permanent(
104                SettlementFailureCode::InvalidActionHash,
105                "receipt action hash did not verify",
106            );
107        }
108        Err(error) => {
109            return permanent(SettlementFailureCode::InvalidActionHash, error.to_string());
110        }
111    }
112    if trusted_kernel_keys.is_empty()
113        || !trusted_kernel_keys
114            .iter()
115            .any(|trusted| trusted == &receipt.kernel_key)
116    {
117        return permanent(
118            SettlementFailureCode::UntrustedReceiptSigner,
119            "receipt signer is not trusted",
120        );
121    }
122    let metadata = receipt.metadata.as_ref();
123    let channelized = if let Some(channel_value) = metadata.and_then(|value| value.get("channel")) {
124        match serde_json::from_value::<ChannelReceiptMetadataV1>(channel_value.clone()) {
125            Ok(channel) if channel.is_valid() => true,
126            Ok(_) | Err(_) => {
127                return permanent(
128                    SettlementFailureCode::InvalidObservation,
129                    "channel metadata is malformed",
130                );
131            }
132        }
133    } else {
134        false
135    };
136    if channelized {
137        if !receipt.is_allowed() && !receipt.is_denied() {
138            return permanent(
139                SettlementFailureCode::InvalidObservation,
140                "receipt does not contain an authorized terminal decision",
141            );
142        }
143        if receipt.financial_metadata().is_none() {
144            return permanent(
145                SettlementFailureCode::MalformedFinancialMetadata,
146                "channel financial metadata is malformed",
147            );
148        }
149        return permanent(
150            SettlementFailureCode::InvalidObservation,
151            "channel settlement handler is not configured",
152        );
153    }
154    if receipt.is_denied() {
155        return SettlementObservationBuild::Skipped(SettlementSkipReason::Denied);
156    }
157    if !receipt.is_allowed() {
158        return permanent(
159            SettlementFailureCode::InvalidObservation,
160            "receipt does not contain an authorized terminal decision",
161        );
162    }
163
164    let Some(metadata) = metadata else {
165        return SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent);
166    };
167    let Some(financial_value) = metadata.get("financial") else {
168        return SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent);
169    };
170    let Some(financial) = financial_value.as_object() else {
171        return permanent(
172            SettlementFailureCode::MalformedFinancialMetadata,
173            "financial metadata is not an object",
174        );
175    };
176    let Some(charged_value) = financial.get("cost_charged") else {
177        return permanent(
178            SettlementFailureCode::MalformedFinancialMetadata,
179            "financial metadata is missing cost_charged",
180        );
181    };
182    let Some(units) = charged_value.as_u64() else {
183        return permanent(
184            SettlementFailureCode::MalformedFinancialMetadata,
185            "cost_charged is not an unsigned integer",
186        );
187    };
188    if units == 0 {
189        return SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge);
190    }
191    let Some(currency) = financial
192        .get("currency")
193        .and_then(serde_json::Value::as_str)
194    else {
195        return permanent(
196            SettlementFailureCode::MalformedFinancialMetadata,
197            "positive cost_charged requires a currency",
198        );
199    };
200    // ISO 4217 alphabetic codes are the money path's invariant, enforced identically
201    // on the charge path, payment journal, cumulative approval, receipt query, and
202    // channel and EVM settlement. A code that does not conform is malformed metadata
203    // rather than a new denomination, so classifying it permanent is correct. A
204    // backfill that enrolls receipts written before this rule must revisit this
205    // classification first, since those receipts were accepted without a format check.
206    if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
207        return permanent(
208            SettlementFailureCode::MalformedFinancialMetadata,
209            "positive cost_charged requires a three-letter uppercase currency",
210        );
211    }
212
213    let observation = SettlementObservation::new(
214        receipt.id.clone(),
215        receipt.timestamp,
216        receipt.tool_server.clone(),
217        receipt.tool_name.clone(),
218        receipt.capability_id.clone(),
219        chio_core::capability::scope::MonetaryAmount {
220            currency: currency.to_string(),
221            units,
222        },
223        receipt.content_hash.clone(),
224        receipt.policy_hash.clone(),
225    );
226
227    SettlementObservationBuild::Observation(if let Some(tenant_id) = receipt.tenant_id.clone() {
228        observation.with_tenant(tenant_id)
229    } else {
230        observation
231    })
232}
233
234/// Run the registered settlement hook against a freshly signed receipt.
235///
236/// Settlement is observer-only relative to receipt bytes: the receipt
237/// has already been signed and persisted before this function runs,
238/// and the returned status NEVER feeds back into the dispatch path.
239/// The function is plumbed through the kernel struct in
240/// [`super::ChioKernel::run_settlement_observer`] so callers only
241/// need the kernel handle.
242#[must_use]
243pub fn run_observer(
244    hook: Option<&Arc<dyn SettlementHook>>,
245    receipt: &ChioReceipt,
246    trusted_kernel_keys: &[PublicKey],
247    idempotency_key: &SettlementIdempotencyKey,
248) -> SettlementObserverStatus {
249    let Some(hook) = hook else {
250        return SettlementObserverStatus::NotRegistered;
251    };
252
253    let observation = match build_observation(receipt, trusted_kernel_keys) {
254        SettlementObservationBuild::Observation(observation) => observation,
255        SettlementObservationBuild::Skipped(reason) => {
256            return SettlementObserverStatus::skipped(reason);
257        }
258        SettlementObservationBuild::Permanent(reason) => {
259            return SettlementObserverStatus::HookFailed {
260                class: SettlementFailureClass::Permanent,
261                reason,
262            };
263        }
264    };
265
266    if idempotency_key.receipt_id != observation.receipt_id || idempotency_key.row_version == 0 {
267        return SettlementObserverStatus::hook_failed(&SettlementHookError::InvalidObservation(
268            "settlement idempotency key does not match the claimed receipt".to_string(),
269        ));
270    }
271
272    match hook.observe(&observation, idempotency_key) {
273        Ok(outcome) => SettlementObserverStatus::Observed { outcome },
274        Err(error) => SettlementObserverStatus::hook_failed(&error),
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    use chio_core::capability::scope::MonetaryAmount;
283    use chio_core::crypto::Keypair;
284    use chio_core::receipt::{
285        body::ChioReceiptBody, decision::Decision, decision::ToolCallAction,
286        economics::CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA, kinds::TrustLevel,
287        metadata::GuardEvidence,
288    };
289
290    fn sign_with(body_metadata: serde_json::Value, decision: Decision) -> ChioReceipt {
291        let action = ToolCallAction::from_parameters(serde_json::json!({}))
292            .expect("test tool-call action constructs");
293        sign_with_action(body_metadata, decision, action)
294    }
295
296    fn sign_with_action(
297        body_metadata: serde_json::Value,
298        decision: Decision,
299        action: ToolCallAction,
300    ) -> ChioReceipt {
301        let kp = Keypair::generate();
302        let body = ChioReceiptBody {
303            id: "rcpt-test".to_string(),
304            timestamp: 100,
305            capability_id: "cap-1".to_string(),
306            tool_server: "srv-1".to_string(),
307            tool_name: "tool-1".to_string(),
308            action,
309            decision: Some(decision),
310            receipt_kind: chio_core::receipt::kinds::ReceiptKind::MediatedDecision,
311            boundary_class: chio_core::receipt::kinds::BoundaryClass::Prevent,
312            observation_outcome: None,
313            tool_origin: chio_core::receipt::kinds::ToolOrigin::CallerExecuted,
314            redaction_mode: chio_core::receipt::kinds::RedactionMode::None,
315            actor_chain: Vec::new(),
316            content_hash: "ch-1".to_string(),
317            policy_hash: "ph-1".to_string(),
318            evidence: vec![GuardEvidence {
319                guard_name: "G".to_string(),
320                verdict: true,
321                details: None,
322            }],
323            metadata: Some(body_metadata),
324            trust_level: TrustLevel::default(),
325            tenant_id: None,
326            kernel_key: kp.public_key(),
327            bbs_projection_version: None,
328        };
329        ChioReceipt::sign(body, &kp).expect("test receipt signs")
330    }
331
332    fn channel_metadata() -> serde_json::Value {
333        serde_json::json!({
334            "schema": CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA,
335            "channelId": "a".repeat(64),
336            "openDigest": "b".repeat(64),
337            "reservationId": "c".repeat(64),
338            "reservationDigest": "d".repeat(64),
339            "sequence": 1,
340            "settlementMode": "channelized",
341        })
342    }
343
344    fn idempotency_key(receipt: &ChioReceipt) -> SettlementIdempotencyKey {
345        SettlementIdempotencyKey {
346            receipt_id: receipt.id.clone(),
347            row_version: 1,
348        }
349    }
350
351    struct AcceptingHook;
352    impl SettlementHook for AcceptingHook {
353        fn observe(
354            &self,
355            observation: &SettlementObservation,
356            idempotency_key: &SettlementIdempotencyKey,
357        ) -> Result<SettlementOutcome, SettlementHookError> {
358            assert_eq!(idempotency_key.receipt_id, observation.receipt_id);
359            assert_eq!(idempotency_key.row_version, 1);
360            Ok(SettlementOutcome::accepted(format!(
361                "ts-{}",
362                observation.receipt_id
363            )))
364        }
365    }
366
367    struct FailingHook;
368    impl SettlementHook for FailingHook {
369        fn observe(
370            &self,
371            _observation: &SettlementObservation,
372            _idempotency_key: &SettlementIdempotencyKey,
373        ) -> Result<SettlementOutcome, SettlementHookError> {
374            Err(SettlementHookError::Transient("rpc lag".to_string()))
375        }
376    }
377
378    #[test]
379    fn build_observation_skips_explicit_deny() {
380        let receipt = sign_with(
381            serde_json::json!({
382                "financial": {"cost_charged": 100, "currency": "USD"}
383            }),
384            Decision::Deny {
385                reason: "denied".to_string(),
386                guard: "G".to_string(),
387            },
388        );
389        assert!(matches!(
390            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
391            SettlementObservationBuild::Skipped(SettlementSkipReason::Denied)
392        ));
393    }
394
395    #[test]
396    fn build_observation_rejects_nonterminal_decisions() {
397        let cases = [
398            Decision::Cancelled {
399                reason: "cancelled".to_string(),
400            },
401            Decision::Incomplete {
402                reason: "incomplete".to_string(),
403            },
404        ];
405
406        for decision in cases {
407            let receipt = sign_with(
408                serde_json::json!({
409                    "financial": {"cost_charged": 100, "currency": "USD"}
410                }),
411                decision,
412            );
413            assert!(matches!(
414                build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
415                SettlementObservationBuild::Permanent(ref reason)
416                    if reason.code() == SettlementFailureCode::InvalidObservation
417            ));
418        }
419    }
420
421    #[test]
422    fn build_observation_skips_zero_priced_receipts() {
423        let receipt = sign_with(
424            serde_json::json!({
425                "financial": {"cost_charged": 0, "currency": "USD"}
426            }),
427            Decision::Allow,
428        );
429        assert!(matches!(
430            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
431            SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge)
432        ));
433    }
434
435    #[test]
436    fn build_observation_skips_when_metadata_missing_financial_section() {
437        let receipt = sign_with(serde_json::json!({}), Decision::Allow);
438        assert!(matches!(
439            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
440            SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent)
441        ));
442    }
443
444    #[test]
445    fn build_observation_rejects_channelized_receipts_with_malformed_financial_metadata() {
446        let receipt = sign_with(
447            serde_json::json!({
448                "channel": channel_metadata(),
449                "financial": "invalid"
450            }),
451            Decision::Allow,
452        );
453        assert!(matches!(
454            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
455            SettlementObservationBuild::Permanent(ref reason)
456                if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
457        ));
458    }
459
460    #[test]
461    fn build_observation_rejects_channelized_receipts_without_financial_metadata() {
462        let receipt = sign_with(
463            serde_json::json!({"channel": channel_metadata()}),
464            Decision::Allow,
465        );
466        assert!(matches!(
467            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
468            SettlementObservationBuild::Permanent(ref reason)
469                if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
470        ));
471    }
472
473    #[test]
474    fn build_observation_rejects_denied_channelized_receipts_without_a_channel_handler() {
475        let receipt = sign_with(
476            serde_json::json!({
477                "channel": channel_metadata(),
478                "financial": {
479                    "grant_index": 0,
480                    "cost_charged": 0,
481                    "currency": "USD",
482                    "budget_remaining": 1000,
483                    "budget_total": 1000,
484                    "delegation_depth": 0,
485                    "root_budget_holder": "payer",
486                    "settlement_status": "not_applicable"
487                }
488            }),
489            Decision::Deny {
490                reason: "denied".to_owned(),
491                guard: "G".to_owned(),
492            },
493        );
494        assert!(matches!(
495            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
496            SettlementObservationBuild::Permanent(ref reason)
497                if reason.code() == SettlementFailureCode::InvalidObservation
498        ));
499    }
500
501    #[test]
502    fn build_observation_rejects_channelized_receipts_without_a_channel_handler() {
503        let receipt = sign_with(
504            serde_json::json!({
505                "channel": channel_metadata(),
506                "financial": {
507                    "grant_index": 0,
508                    "cost_charged": 250,
509                    "currency": "USD",
510                    "budget_remaining": 750,
511                    "budget_total": 1000,
512                    "delegation_depth": 0,
513                    "root_budget_holder": "payer",
514                    "settlement_status": "pending"
515                }
516            }),
517            Decision::Allow,
518        );
519        assert!(matches!(
520            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
521            SettlementObservationBuild::Permanent(ref reason)
522                if reason.code() == SettlementFailureCode::InvalidObservation
523        ));
524    }
525
526    #[test]
527    fn build_observation_rejects_malformed_channel_metadata() {
528        let receipt = sign_with(
529            serde_json::json!({
530                "channel": {"schema": CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA},
531                "financial": {"cost_charged": 250, "currency": "USD"}
532            }),
533            Decision::Allow,
534        );
535        assert!(matches!(
536            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
537            SettlementObservationBuild::Permanent(ref reason)
538                if reason.code() == SettlementFailureCode::InvalidObservation
539        ));
540    }
541
542    #[test]
543    fn build_observation_validates_integrity_and_trust_before_channel_skip() {
544        let mut invalid_signature = sign_with(
545            serde_json::json!({"channel": channel_metadata()}),
546            Decision::Allow,
547        );
548        invalid_signature.tool_name = "tampered".to_string();
549        assert!(matches!(
550            build_observation(
551                &invalid_signature,
552                std::slice::from_ref(&invalid_signature.kernel_key)
553            ),
554            SettlementObservationBuild::Permanent(ref reason)
555                if reason.code() == SettlementFailureCode::InvalidReceiptSignature
556        ));
557
558        let untrusted = sign_with(
559            serde_json::json!({"channel": channel_metadata()}),
560            Decision::Allow,
561        );
562        assert!(matches!(
563            build_observation(&untrusted, &[]),
564            SettlementObservationBuild::Permanent(ref reason)
565                if reason.code() == SettlementFailureCode::UntrustedReceiptSigner
566        ));
567    }
568
569    #[test]
570    fn build_observation_rejects_invalid_signature() {
571        let mut receipt = sign_with(
572            serde_json::json!({
573                "financial": {"cost_charged": 250, "currency": "USD"}
574            }),
575            Decision::Allow,
576        );
577        receipt.tool_name = "tampered".to_string();
578        assert!(matches!(
579            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
580            SettlementObservationBuild::Permanent(ref reason)
581                if reason.code() == SettlementFailureCode::InvalidReceiptSignature
582        ));
583    }
584
585    #[test]
586    fn build_observation_rejects_mismatched_action_hash() {
587        let mut action = ToolCallAction::from_parameters(serde_json::json!({"path": "/tmp/a"}))
588            .expect("test tool-call action constructs");
589        action.parameters = serde_json::json!({"path": "/tmp/b"});
590        let receipt = sign_with_action(
591            serde_json::json!({
592                "financial": {"cost_charged": 250, "currency": "USD"}
593            }),
594            Decision::Allow,
595            action,
596        );
597        assert!(matches!(
598            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
599            SettlementObservationBuild::Permanent(ref reason)
600                if reason.code() == SettlementFailureCode::InvalidActionHash
601        ));
602    }
603
604    #[test]
605    fn build_observation_constructs_priced_frame() {
606        let receipt = sign_with(
607            serde_json::json!({
608                "financial": {"cost_charged": 250, "currency": "USD"}
609            }),
610            Decision::Allow,
611        );
612        let SettlementObservationBuild::Observation(observation) =
613            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key))
614        else {
615            panic!("priced receipt did not yield an observation");
616        };
617        assert_eq!(observation.receipt_id, receipt.id);
618        assert_eq!(observation.finalized_at, 100);
619        assert_eq!(
620            observation.amount,
621            MonetaryAmount {
622                currency: "USD".to_string(),
623                units: 250,
624            }
625        );
626        assert_eq!(observation.content_hash, "ch-1");
627    }
628
629    #[test]
630    fn build_observation_rejects_untrusted_signer() {
631        let receipt = sign_with(
632            serde_json::json!({
633                "financial": {"cost_charged": 250, "currency": "USD"}
634            }),
635            Decision::Allow,
636        );
637        assert!(matches!(
638            build_observation(&receipt, &[]),
639            SettlementObservationBuild::Permanent(ref reason)
640                if reason.code() == SettlementFailureCode::UntrustedReceiptSigner
641        ));
642    }
643
644    #[test]
645    fn run_observer_returns_not_registered_without_hook() {
646        let receipt = sign_with(
647            serde_json::json!({
648                "financial": {"cost_charged": 250, "currency": "USD"}
649            }),
650            Decision::Allow,
651        );
652        let status = run_observer(
653            None,
654            &receipt,
655            std::slice::from_ref(&receipt.kernel_key),
656            &idempotency_key(&receipt),
657        );
658        assert!(matches!(status, SettlementObserverStatus::NotRegistered));
659    }
660
661    #[test]
662    fn run_observer_records_hook_outcome() {
663        let receipt = sign_with(
664            serde_json::json!({
665                "financial": {"cost_charged": 250, "currency": "USD"}
666            }),
667            Decision::Allow,
668        );
669        let hook: Arc<dyn SettlementHook> = Arc::new(AcceptingHook);
670        let status = run_observer(
671            Some(&hook),
672            &receipt,
673            std::slice::from_ref(&receipt.kernel_key),
674            &idempotency_key(&receipt),
675        );
676        match status {
677            SettlementObserverStatus::Observed {
678                outcome: SettlementOutcome::Accepted { transcript_id, .. },
679            } => assert_eq!(transcript_id, format!("ts-{}", receipt.id)),
680            other => panic!("expected accepted outcome, got {other:?}"),
681        }
682    }
683
684    #[test]
685    fn run_observer_skips_zero_price_without_invoking_hook() {
686        let receipt = sign_with(
687            serde_json::json!({
688                "financial": {"cost_charged": 0, "currency": "USD"}
689            }),
690            Decision::Allow,
691        );
692        let hook: Arc<dyn SettlementHook> = Arc::new(FailingHook);
693        let status = run_observer(
694            Some(&hook),
695            &receipt,
696            std::slice::from_ref(&receipt.kernel_key),
697            &idempotency_key(&receipt),
698        );
699        assert!(matches!(
700            status,
701            SettlementObserverStatus::Skipped {
702                reason: SettlementSkipReason::ZeroCharge,
703            }
704        ));
705    }
706
707    #[test]
708    fn build_observation_reads_canonical_financial_metadata() {
709        // The kernel canonical financial-metadata shape is
710        // `FinancialReceiptMetadata` (`cost_charged`, `currency`,
711        // `attempted_cost`). Receipts emitted by the kernel's normal
712        // finalize path use this shape, so the settlement observer
713        // MUST recognize it.
714        let receipt = sign_with(
715            serde_json::json!({
716                "financial": {
717                    "grant_index": 0,
718                    "cost_charged": 250,
719                    "currency": "USD",
720                    "budget_remaining": 750,
721                    "budget_total": 1000,
722                    "delegation_depth": 1,
723                    "root_budget_holder": "tenant-a",
724                    "settlement_status": "pending"
725                }
726            }),
727            Decision::Allow,
728        );
729        let SettlementObservationBuild::Observation(observation) =
730            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key))
731        else {
732            panic!("canonical financial metadata did not yield an observation");
733        };
734        assert_eq!(observation.amount.units, 250);
735        assert_eq!(observation.amount.currency, "USD");
736    }
737
738    #[test]
739    fn build_observation_skips_zero_cost_charged() {
740        let receipt = sign_with(
741            serde_json::json!({
742                "financial": {
743                    "cost_charged": 0,
744                    "currency": "USD"
745                }
746            }),
747            Decision::Allow,
748        );
749        assert!(matches!(
750            build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
751            SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge)
752        ));
753    }
754
755    #[test]
756    fn run_observer_records_hook_failures_without_panicking() {
757        let receipt = sign_with(
758            serde_json::json!({
759                "financial": {"cost_charged": 250, "currency": "USD"}
760            }),
761            Decision::Allow,
762        );
763        let hook: Arc<dyn SettlementHook> = Arc::new(FailingHook);
764        let status = run_observer(
765            Some(&hook),
766            &receipt,
767            std::slice::from_ref(&receipt.kernel_key),
768            &idempotency_key(&receipt),
769        );
770        assert!(matches!(
771            status,
772            SettlementObserverStatus::HookFailed {
773                class: SettlementFailureClass::Retryable,
774                reason,
775            } if reason.code() == SettlementFailureCode::Backend
776        ));
777    }
778
779    #[test]
780    fn build_observation_rejects_malformed_positive_financial_metadata() {
781        let cases = [
782            serde_json::json!({"financial": {"currency": "USD"}}),
783            serde_json::json!({"financial": {"cost_charged": "250", "currency": "USD"}}),
784            serde_json::json!({"financial": {"cost_charged": 250}}),
785            serde_json::json!({"financial": {"cost_charged": 250, "currency": "usd"}}),
786            serde_json::json!({"financial": {"cost_charged": 250, "currency": "US D"}}),
787            serde_json::json!({"financial": "invalid"}),
788        ];
789
790        for metadata in cases {
791            let receipt = sign_with(metadata, Decision::Allow);
792            assert!(matches!(
793                build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
794                SettlementObservationBuild::Permanent(ref reason)
795                    if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
796            ));
797        }
798    }
799}