Skip to main content

chio_settle/
hook.rs

1//! Settlement hook trait routing finalized Chio receipts through the
2//! existing `chio-settle/ops.rs` pipeline.
3//!
4//! This exposes a kernel-evaluator observer surface for the
5//! `chio-settle` crate. The hook is invoked once a receipt has been
6//! signed and durably stored. A paired observer runtime is responsible
7//! for persisting hook outcomes and routing retries or dead letters.
8//!
9//! Settlement ordering is deterministic: implementers MUST process
10//! observations sorted first by [`SettlementObservation::finalized_at`]
11//! ascending and then by [`SettlementObservation::receipt_id`] lexically.
12//!
13//! Integrity and malformed-data failures are permanent. Only denied,
14//! non-economic, and zero-charge receipts may be skipped. The dispatch
15//! path is never rolled back by a settlement failure.
16
17use serde::{Deserialize, Deserializer, Serialize};
18use thiserror::Error;
19
20use chio_core::{capability::scope::MonetaryAmount, hashing::sha256};
21
22use crate::SettlementError;
23
24/// Schema string emitted on the wire for [`SettlementObservation`] frames.
25pub const SETTLEMENT_OBSERVATION_SCHEMA: &str = "chio.settle.observation.v1";
26
27/// Schema string emitted on the wire for [`SettlementOutcome`] frames.
28pub const SETTLEMENT_OUTCOME_SCHEMA: &str = "chio.settle.outcome.v1";
29
30fn deserialize_schema<'de, D>(
31    deserializer: D,
32    expected: &'static str,
33    error: &'static str,
34) -> Result<String, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    let schema = String::deserialize(deserializer)?;
39    if schema == expected {
40        Ok(schema)
41    } else {
42        Err(serde::de::Error::custom(error))
43    }
44}
45
46fn deserialize_observation_schema<'de, D>(deserializer: D) -> Result<String, D::Error>
47where
48    D: Deserializer<'de>,
49{
50    deserialize_schema(
51        deserializer,
52        SETTLEMENT_OBSERVATION_SCHEMA,
53        "unsupported settlement observation schema",
54    )
55}
56
57fn deserialize_outcome_schema<'de, D>(deserializer: D) -> Result<String, D::Error>
58where
59    D: Deserializer<'de>,
60{
61    deserialize_schema(
62        deserializer,
63        SETTLEMENT_OUTCOME_SCHEMA,
64        "unsupported settlement outcome schema",
65    )
66}
67
68/// Observation handed to a [`SettlementHook`] by the kernel observer
69/// slot once a receipt has been signed and persisted. The structure is
70/// deliberately storage-agnostic: it carries the finalized-receipt
71/// identity plus the financial coordinates required to route the
72/// settlement through `chio-settle/ops.rs`.
73///
74/// The kernel sets [`finalized_at`] to the receipt timestamp so a hook
75/// implementation can sort by `(finalized_at, receipt_id)` to guarantee
76/// the deterministic ordering the integration tests enforce.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(deny_unknown_fields)]
79pub struct SettlementObservation {
80    /// Schema tag (`chio.settle.observation.v1`).
81    #[serde(deserialize_with = "deserialize_observation_schema")]
82    pub schema: String,
83    /// `id` of the finalized [`chio_core::receipt::body::ChioReceipt`].
84    pub receipt_id: String,
85    /// `timestamp` carried over from the receipt (deterministic sort key).
86    pub finalized_at: u64,
87    /// Cluster operator (tenant) that owes the obligation, or `None`
88    /// for single-tenant deployments.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub tenant_id: Option<String>,
91    /// Tool server invoked.
92    pub tool_server: String,
93    /// Tool name invoked.
94    pub tool_name: String,
95    /// Capability id matched at evaluation time.
96    pub capability_id: String,
97    /// Settlement amount derived from the manifest pricing context. The
98    /// kernel skips the hook entirely for zero-priced receipts; the
99    /// amount on this struct is therefore always strictly positive.
100    pub amount: MonetaryAmount,
101    /// Receipt content hash, carried verbatim so a downstream auditor
102    /// can confirm the settlement references the same bytes the kernel
103    /// signed.
104    pub content_hash: String,
105    /// Receipt policy hash, carried verbatim for the same reason.
106    pub policy_hash: String,
107}
108
109/// Durable identity and claim fence for one at-least-once hook invocation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SettlementIdempotencyKey {
112    /// Stable effect identity. Hooks must deduplicate durable effects by this id.
113    pub receipt_id: String,
114    /// Store row version claimed for this invocation.
115    pub row_version: u64,
116}
117
118impl SettlementObservation {
119    /// Construct a fresh observation, stamping the canonical schema tag.
120    #[must_use]
121    #[allow(clippy::too_many_arguments)]
122    pub fn new(
123        receipt_id: impl Into<String>,
124        finalized_at: u64,
125        tool_server: impl Into<String>,
126        tool_name: impl Into<String>,
127        capability_id: impl Into<String>,
128        amount: MonetaryAmount,
129        content_hash: impl Into<String>,
130        policy_hash: impl Into<String>,
131    ) -> Self {
132        Self {
133            schema: SETTLEMENT_OBSERVATION_SCHEMA.to_string(),
134            receipt_id: receipt_id.into(),
135            finalized_at,
136            tenant_id: None,
137            tool_server: tool_server.into(),
138            tool_name: tool_name.into(),
139            capability_id: capability_id.into(),
140            amount,
141            content_hash: content_hash.into(),
142            policy_hash: policy_hash.into(),
143        }
144    }
145
146    /// Attach a tenant identifier. Single-tenant deployments may leave
147    /// this unset.
148    #[must_use]
149    pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
150        self.tenant_id = Some(tenant_id.into());
151        self
152    }
153
154    /// Return the deterministic sort key used by hook implementations.
155    /// Tuples sort lexicographically by `(finalized_at, receipt_id)`.
156    #[must_use]
157    pub fn ordering_key(&self) -> (u64, &str) {
158        (self.finalized_at, self.receipt_id.as_str())
159    }
160}
161
162/// Closed reason for an observation that requires no settlement work.
163#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "snake_case")]
165pub enum SettlementSkipReason {
166    /// The receipt records a denied invocation.
167    Denied,
168    /// The receipt carries no authorized economic intent.
169    NoEconomicIntent,
170    Channelized,
171    /// The authorized invocation has no charge.
172    ZeroCharge,
173}
174
175/// Retry disposition for a settlement failure.
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
177#[serde(rename_all = "snake_case")]
178pub enum SettlementFailureClass {
179    /// The operation may succeed when replayed.
180    Retryable,
181    /// Replaying the same operation cannot succeed.
182    Permanent,
183}
184
185/// Closed settlement failure taxonomy.
186#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(rename_all = "snake_case")]
188pub enum SettlementFailureCode {
189    InvalidReceiptSignature,
190    InvalidActionHash,
191    UntrustedReceiptSigner,
192    MalformedFinancialMetadata,
193    InvalidObservation,
194    Rpc,
195    InvalidInput,
196    InvalidDispatch,
197    InvalidBinding,
198    Unsupported,
199    Serialization,
200    Signature,
201    Verification,
202    Backend,
203}
204
205/// Unknown durable settlement failure code.
206#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
207#[error("unknown settlement failure code")]
208pub struct SettlementFailureCodeParseError;
209
210impl SettlementFailureCode {
211    /// Return the stable snake-case label used by durable stores.
212    #[must_use]
213    pub const fn as_str(self) -> &'static str {
214        match self {
215            Self::InvalidReceiptSignature => "invalid_receipt_signature",
216            Self::InvalidActionHash => "invalid_action_hash",
217            Self::UntrustedReceiptSigner => "untrusted_receipt_signer",
218            Self::MalformedFinancialMetadata => "malformed_financial_metadata",
219            Self::InvalidObservation => "invalid_observation",
220            Self::Rpc => "rpc",
221            Self::InvalidInput => "invalid_input",
222            Self::InvalidDispatch => "invalid_dispatch",
223            Self::InvalidBinding => "invalid_binding",
224            Self::Unsupported => "unsupported",
225            Self::Serialization => "serialization",
226            Self::Signature => "signature",
227            Self::Verification => "verification",
228            Self::Backend => "backend",
229        }
230    }
231
232    const fn allows_retry(self) -> bool {
233        matches!(self, Self::Rpc | Self::Backend)
234    }
235}
236
237impl TryFrom<&str> for SettlementFailureCode {
238    type Error = SettlementFailureCodeParseError;
239
240    fn try_from(value: &str) -> Result<Self, Self::Error> {
241        match value {
242            "invalid_receipt_signature" => Ok(Self::InvalidReceiptSignature),
243            "invalid_action_hash" => Ok(Self::InvalidActionHash),
244            "untrusted_receipt_signer" => Ok(Self::UntrustedReceiptSigner),
245            "malformed_financial_metadata" => Ok(Self::MalformedFinancialMetadata),
246            "invalid_observation" => Ok(Self::InvalidObservation),
247            "rpc" => Ok(Self::Rpc),
248            "invalid_input" => Ok(Self::InvalidInput),
249            "invalid_dispatch" => Ok(Self::InvalidDispatch),
250            "invalid_binding" => Ok(Self::InvalidBinding),
251            "unsupported" => Ok(Self::Unsupported),
252            "serialization" => Ok(Self::Serialization),
253            "signature" => Ok(Self::Signature),
254            "verification" => Ok(Self::Verification),
255            "backend" => Ok(Self::Backend),
256            _ => Err(SettlementFailureCodeParseError),
257        }
258    }
259}
260
261/// Bounded failure reason safe for durable storage and telemetry labels.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(deny_unknown_fields)]
264pub struct SettlementFailureReason {
265    code: SettlementFailureCode,
266    detail_sha256: [u8; 32],
267}
268
269impl SettlementFailureReason {
270    /// Hash an unbounded failure detail into its durable representation.
271    #[must_use]
272    pub fn from_detail(code: SettlementFailureCode, detail: impl AsRef<[u8]>) -> Self {
273        Self::from_digest(code, *sha256(detail.as_ref()).as_bytes())
274    }
275
276    /// Restore a reason from a persisted digest.
277    #[must_use]
278    pub const fn from_digest(code: SettlementFailureCode, detail_sha256: [u8; 32]) -> Self {
279        Self {
280            code,
281            detail_sha256,
282        }
283    }
284
285    /// Return the bounded failure code.
286    #[must_use]
287    pub const fn code(&self) -> SettlementFailureCode {
288        self.code
289    }
290
291    /// Return the SHA-256 digest of the original detail.
292    #[must_use]
293    pub const fn detail_sha256(&self) -> &[u8; 32] {
294        &self.detail_sha256
295    }
296
297    /// Enforce the retry disposition permitted by this failure code.
298    #[must_use]
299    pub const fn effective_class(
300        &self,
301        requested: SettlementFailureClass,
302    ) -> SettlementFailureClass {
303        match requested {
304            SettlementFailureClass::Retryable if self.code.allows_retry() => {
305                SettlementFailureClass::Retryable
306            }
307            SettlementFailureClass::Retryable | SettlementFailureClass::Permanent => {
308                SettlementFailureClass::Permanent
309            }
310        }
311    }
312}
313
314/// Outcome returned by a settlement hook.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
317pub enum SettlementOutcome {
318    /// The hook accepted the observation and routed it through the
319    /// existing `chio-settle/ops.rs` pipeline. The opaque transcript
320    /// id lets operators correlate the kernel-side observation with
321    /// the downstream settlement record.
322    Accepted {
323        /// Schema tag (`chio.settle.outcome.v1`).
324        #[serde(deserialize_with = "deserialize_outcome_schema")]
325        schema: String,
326        /// Stable transcript identifier produced by the ops pipeline.
327        transcript_id: String,
328    },
329    /// The receipt requires no settlement work.
330    Skipped {
331        /// Schema tag (`chio.settle.outcome.v1`).
332        #[serde(deserialize_with = "deserialize_outcome_schema")]
333        schema: String,
334        /// Closed reason for skipping settlement.
335        reason: SettlementSkipReason,
336    },
337    /// The hook rejected the observation with a recoverable failure.
338    Retryable {
339        /// Schema tag (`chio.settle.outcome.v1`).
340        #[serde(deserialize_with = "deserialize_outcome_schema")]
341        schema: String,
342        /// Bounded failure reason carried across retries.
343        reason: SettlementFailureReason,
344    },
345    /// The hook rejected the observation permanently.
346    Permanent {
347        /// Schema tag (`chio.settle.outcome.v1`).
348        #[serde(deserialize_with = "deserialize_outcome_schema")]
349        schema: String,
350        /// Bounded failure reason carried into the dead letter.
351        reason: SettlementFailureReason,
352    },
353}
354
355impl SettlementOutcome {
356    /// Return whether this outcome uses the schema understood by this build.
357    #[must_use]
358    pub fn has_supported_schema(&self) -> bool {
359        let schema = match self {
360            Self::Accepted { schema, .. }
361            | Self::Skipped { schema, .. }
362            | Self::Retryable { schema, .. }
363            | Self::Permanent { schema, .. } => schema,
364        };
365        schema == SETTLEMENT_OUTCOME_SCHEMA
366    }
367
368    /// Construct an `Accepted` outcome with the canonical schema tag.
369    #[must_use]
370    pub fn accepted(transcript_id: impl Into<String>) -> Self {
371        Self::Accepted {
372            schema: SETTLEMENT_OUTCOME_SCHEMA.to_string(),
373            transcript_id: transcript_id.into(),
374        }
375    }
376
377    /// Construct a `Skipped` outcome with the canonical schema tag.
378    #[must_use]
379    pub fn skipped(reason: SettlementSkipReason) -> Self {
380        Self::Skipped {
381            schema: SETTLEMENT_OUTCOME_SCHEMA.to_string(),
382            reason,
383        }
384    }
385
386    /// Construct a retryable outcome with the canonical schema tag.
387    ///
388    /// Deterministic failure codes are coerced to [`Self::Permanent`].
389    #[must_use]
390    pub fn retryable(reason: SettlementFailureReason) -> Self {
391        match reason.effective_class(SettlementFailureClass::Retryable) {
392            SettlementFailureClass::Retryable => Self::Retryable {
393                schema: SETTLEMENT_OUTCOME_SCHEMA.to_string(),
394                reason,
395            },
396            SettlementFailureClass::Permanent => Self::permanent(reason),
397        }
398    }
399
400    /// Construct a `Permanent` outcome with the canonical schema tag.
401    #[must_use]
402    pub fn permanent(reason: SettlementFailureReason) -> Self {
403        Self::Permanent {
404            schema: SETTLEMENT_OUTCOME_SCHEMA.to_string(),
405            reason,
406        }
407    }
408
409    /// Return `true` for outcomes that the retry policy must replay.
410    #[must_use]
411    pub fn is_retryable(&self) -> bool {
412        matches!(
413            self,
414            Self::Retryable { reason, .. }
415                if reason.effective_class(SettlementFailureClass::Retryable)
416                    == SettlementFailureClass::Retryable
417        )
418    }
419
420    /// Return `true` for outcomes that land directly in the dead-letter
421    /// table without further retries.
422    #[must_use]
423    pub fn is_permanent(&self) -> bool {
424        match self {
425            Self::Permanent { .. } => true,
426            Self::Retryable { reason, .. } => {
427                reason.effective_class(SettlementFailureClass::Retryable)
428                    == SettlementFailureClass::Permanent
429            }
430            Self::Accepted { .. } | Self::Skipped { .. } => false,
431        }
432    }
433}
434
435/// Errors that may surface from a [`SettlementHook`]. All variants are
436/// fail-closed: the paired observer runtime preserves their typed disposition
437/// for durable routing, while the dispatch path is never rolled back.
438#[derive(Debug, Error)]
439pub enum SettlementHookError {
440    /// The supplied observation was malformed.
441    #[error("invalid observation: {0}")]
442    InvalidObservation(String),
443    /// The downstream settlement pipeline reported a transient error.
444    /// Implementations SHOULD prefer [`SettlementOutcome::Retryable`]
445    /// over surfacing this variant; it is provided for hooks that
446    /// cannot classify the failure synchronously.
447    #[error("transient settlement failure: {0}")]
448    Transient(String),
449    /// The downstream settlement pipeline reported a permanent error.
450    #[error("permanent settlement failure: {0}")]
451    Permanent(String),
452    /// A lower-level [`SettlementError`] surfaced from the ops pipeline.
453    #[error("settlement pipeline error: {0}")]
454    Pipeline(#[from] SettlementError),
455}
456
457impl SettlementHookError {
458    /// Return the retry disposition and bounded reason for this error.
459    #[must_use]
460    pub fn classification(&self) -> (SettlementFailureClass, SettlementFailureReason) {
461        let (class, code, detail) = match self {
462            Self::InvalidObservation(detail) => (
463                SettlementFailureClass::Permanent,
464                SettlementFailureCode::InvalidObservation,
465                detail.as_str(),
466            ),
467            Self::Transient(detail) => (
468                SettlementFailureClass::Retryable,
469                SettlementFailureCode::Backend,
470                detail.as_str(),
471            ),
472            Self::Permanent(detail) => (
473                SettlementFailureClass::Permanent,
474                SettlementFailureCode::Backend,
475                detail.as_str(),
476            ),
477            Self::Pipeline(error) => match error {
478                SettlementError::Rpc(detail) => (
479                    SettlementFailureClass::Retryable,
480                    SettlementFailureCode::Rpc,
481                    detail.as_str(),
482                ),
483                SettlementError::InvalidInput(detail) => (
484                    SettlementFailureClass::Permanent,
485                    SettlementFailureCode::InvalidInput,
486                    detail.as_str(),
487                ),
488                SettlementError::InvalidDispatch(detail) => (
489                    SettlementFailureClass::Permanent,
490                    SettlementFailureCode::InvalidDispatch,
491                    detail.as_str(),
492                ),
493                SettlementError::InvalidBinding(detail) => (
494                    SettlementFailureClass::Permanent,
495                    SettlementFailureCode::InvalidBinding,
496                    detail.as_str(),
497                ),
498                SettlementError::Unsupported(detail) => (
499                    SettlementFailureClass::Permanent,
500                    SettlementFailureCode::Unsupported,
501                    detail.as_str(),
502                ),
503                SettlementError::Serialization(detail) => (
504                    SettlementFailureClass::Permanent,
505                    SettlementFailureCode::Serialization,
506                    detail.as_str(),
507                ),
508                SettlementError::Signature(detail) => (
509                    SettlementFailureClass::Permanent,
510                    SettlementFailureCode::Signature,
511                    detail.as_str(),
512                ),
513                SettlementError::Verification(detail) => (
514                    SettlementFailureClass::Permanent,
515                    SettlementFailureCode::Verification,
516                    detail.as_str(),
517                ),
518            },
519        };
520
521        (class, SettlementFailureReason::from_detail(code, detail))
522    }
523}
524
525/// Hook routing finalized receipts through `chio-settle/ops.rs`.
526///
527/// The trait is dyn-compatible so the kernel observer slot can hold a
528/// `Arc<dyn SettlementHook>`. Implementations MUST:
529///
530/// - Treat `observe` as observer-only relative to receipt bytes:
531///   the receipt is already signed and persisted before this method
532///   runs, and a hook MUST NOT mutate the receipt store.
533/// - Process observations in `(finalized_at, receipt_id)` order when
534///   batching is necessary (see [`SettlementObservation::ordering_key`]).
535/// - Be safe to call concurrently from a tokio runtime; the kernel
536///   observer slot does not serialize calls.
537/// - Keep `observe` bounded and local. An accepted outcome must follow a
538///   durable local write, not unbounded network I/O.
539/// - Make durable effects idempotent by receipt id. Lease recovery may replay
540///   an observation after a process exits or an invocation exceeds its lease.
541///   The supplied key makes the receipt identity and claim version explicit;
542///   hooks MUST deduplicate effects by `receipt_id` across row versions.
543pub trait SettlementHook: Send + Sync {
544    /// Observe a finalized receipt and route it through the settlement
545    /// pipeline. See the trait-level docs for ordering and failure
546    /// semantics.
547    fn observe(
548        &self,
549        observation: &SettlementObservation,
550        idempotency_key: &SettlementIdempotencyKey,
551    ) -> Result<SettlementOutcome, SettlementHookError>;
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    fn require_ok<T, E>(result: Result<T, E>, context: &'static str) -> T
559    where
560        E: std::fmt::Debug,
561    {
562        result.unwrap_or_else(|error| panic!("{context}: {error:?}"))
563    }
564
565    fn sample_amount() -> MonetaryAmount {
566        MonetaryAmount {
567            currency: "USD".to_string(),
568            units: 100,
569        }
570    }
571
572    fn failure(code: SettlementFailureCode, detail: &str) -> SettlementFailureReason {
573        SettlementFailureReason::from_detail(code, detail)
574    }
575
576    fn serialize<T: Serialize>(value: &T) -> String {
577        match serde_json::to_string(value) {
578            Ok(encoded) => encoded,
579            Err(error) => panic!("value must serialize: {error}"),
580        }
581    }
582
583    #[test]
584    fn observation_schema_is_stable() {
585        assert_eq!(SETTLEMENT_OBSERVATION_SCHEMA, "chio.settle.observation.v1");
586    }
587
588    #[test]
589    fn outcome_schema_is_stable() {
590        assert_eq!(SETTLEMENT_OUTCOME_SCHEMA, "chio.settle.outcome.v1");
591    }
592
593    #[test]
594    fn outcome_deserialization_rejects_an_unsupported_schema() {
595        let result = serde_json::from_value::<SettlementOutcome>(serde_json::json!({
596            "kind": "accepted",
597            "schema": "chio.settle.outcome.v99",
598            "transcript_id": "transcript-1",
599        }));
600
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn ordering_key_sorts_by_finalized_at_then_receipt_id() {
606        let a = SettlementObservation::new(
607            "rcpt-b",
608            10,
609            "srv",
610            "tool",
611            "cap",
612            sample_amount(),
613            "ch",
614            "ph",
615        );
616        let b = SettlementObservation::new(
617            "rcpt-a",
618            10,
619            "srv",
620            "tool",
621            "cap",
622            sample_amount(),
623            "ch",
624            "ph",
625        );
626        let c = SettlementObservation::new(
627            "rcpt-c",
628            5,
629            "srv",
630            "tool",
631            "cap",
632            sample_amount(),
633            "ch",
634            "ph",
635        );
636        let mut frames = [a.clone(), b.clone(), c.clone()];
637        frames.sort_by(|left, right| left.ordering_key().cmp(&right.ordering_key()));
638        assert_eq!(frames[0].receipt_id, "rcpt-c");
639        assert_eq!(frames[1].receipt_id, "rcpt-a");
640        assert_eq!(frames[2].receipt_id, "rcpt-b");
641    }
642
643    #[test]
644    fn outcome_classifiers_match_constructors() {
645        let retry = SettlementOutcome::retryable(failure(SettlementFailureCode::Rpc, "rpc lag"));
646        assert!(retry.is_retryable());
647        assert!(!retry.is_permanent());
648
649        let dead = SettlementOutcome::permanent(failure(
650            SettlementFailureCode::InvalidObservation,
651            "policy denied",
652        ));
653        assert!(!dead.is_retryable());
654        assert!(dead.is_permanent());
655
656        let skip = SettlementOutcome::skipped(SettlementSkipReason::ZeroCharge);
657        assert!(!skip.is_retryable());
658        assert!(!skip.is_permanent());
659
660        let ok = SettlementOutcome::accepted("ts-1");
661        assert!(!ok.is_retryable());
662        assert!(!ok.is_permanent());
663    }
664
665    #[test]
666    fn retryable_constructor_rejects_a_known_permanent_code() {
667        let outcome = SettlementOutcome::retryable(failure(
668            SettlementFailureCode::InvalidReceiptSignature,
669            "invalid signature",
670        ));
671
672        assert!(matches!(outcome, SettlementOutcome::Permanent { .. }));
673    }
674
675    #[test]
676    fn outcome_predicates_reject_a_forged_retryable_shape() {
677        let outcome = match serde_json::from_value::<SettlementOutcome>(serde_json::json!({
678            "kind": "retryable",
679            "schema": SETTLEMENT_OUTCOME_SCHEMA,
680            "reason": {
681                "code": "invalid_receipt_signature",
682                "detail_sha256": vec![0_u8; 32],
683            },
684        })) {
685            Ok(outcome) => outcome,
686            Err(error) => panic!("test outcome deserialization failed: {error}"),
687        };
688
689        assert!(!outcome.is_retryable());
690        assert!(outcome.is_permanent());
691    }
692
693    /// `&dyn SettlementHook` must remain object-safe so kernel observer
694    /// slots can hold a heterogeneous handle.
695    #[test]
696    fn settlement_hook_is_object_safe() {
697        struct NoopHook;
698        impl SettlementHook for NoopHook {
699            fn observe(
700                &self,
701                observation: &SettlementObservation,
702                _idempotency_key: &SettlementIdempotencyKey,
703            ) -> Result<SettlementOutcome, SettlementHookError> {
704                if observation.amount.units == 0 {
705                    return Ok(SettlementOutcome::skipped(SettlementSkipReason::ZeroCharge));
706                }
707                Ok(SettlementOutcome::accepted(format!(
708                    "ts-{}",
709                    observation.receipt_id
710                )))
711            }
712        }
713        let hook: std::sync::Arc<dyn SettlementHook> = std::sync::Arc::new(NoopHook);
714        let observation = SettlementObservation::new(
715            "rcpt-1",
716            42,
717            "srv",
718            "tool",
719            "cap",
720            sample_amount(),
721            "ch",
722            "ph",
723        );
724        let outcome = require_ok(
725            hook.observe(
726                &observation,
727                &SettlementIdempotencyKey {
728                    receipt_id: observation.receipt_id.clone(),
729                    row_version: 1,
730                },
731            ),
732            "hook returns observed outcome",
733        );
734        assert!(matches!(outcome, SettlementOutcome::Accepted { .. }));
735    }
736
737    #[test]
738    fn hook_errors_have_typed_bounded_classification() {
739        let cases = [
740            (
741                SettlementHookError::InvalidObservation("secret".to_string()),
742                SettlementFailureClass::Permanent,
743                SettlementFailureCode::InvalidObservation,
744            ),
745            (
746                SettlementHookError::Transient("secret".to_string()),
747                SettlementFailureClass::Retryable,
748                SettlementFailureCode::Backend,
749            ),
750            (
751                SettlementHookError::Permanent("secret".to_string()),
752                SettlementFailureClass::Permanent,
753                SettlementFailureCode::Backend,
754            ),
755            (
756                SettlementHookError::Pipeline(SettlementError::Rpc("secret".to_string())),
757                SettlementFailureClass::Retryable,
758                SettlementFailureCode::Rpc,
759            ),
760            (
761                SettlementHookError::Pipeline(SettlementError::InvalidInput("secret".to_string())),
762                SettlementFailureClass::Permanent,
763                SettlementFailureCode::InvalidInput,
764            ),
765            (
766                SettlementHookError::Pipeline(SettlementError::InvalidDispatch(
767                    "secret".to_string(),
768                )),
769                SettlementFailureClass::Permanent,
770                SettlementFailureCode::InvalidDispatch,
771            ),
772            (
773                SettlementHookError::Pipeline(SettlementError::InvalidBinding(
774                    "secret".to_string(),
775                )),
776                SettlementFailureClass::Permanent,
777                SettlementFailureCode::InvalidBinding,
778            ),
779            (
780                SettlementHookError::Pipeline(SettlementError::Unsupported("secret".to_string())),
781                SettlementFailureClass::Permanent,
782                SettlementFailureCode::Unsupported,
783            ),
784            (
785                SettlementHookError::Pipeline(SettlementError::Serialization("secret".to_string())),
786                SettlementFailureClass::Permanent,
787                SettlementFailureCode::Serialization,
788            ),
789            (
790                SettlementHookError::Pipeline(SettlementError::Signature("secret".to_string())),
791                SettlementFailureClass::Permanent,
792                SettlementFailureCode::Signature,
793            ),
794            (
795                SettlementHookError::Pipeline(SettlementError::Verification("secret".to_string())),
796                SettlementFailureClass::Permanent,
797                SettlementFailureCode::Verification,
798            ),
799        ];
800
801        for (error, expected_class, expected_code) in cases {
802            let (class, reason) = error.classification();
803            assert_eq!(class, expected_class);
804            assert_eq!(reason.code(), expected_code);
805            assert_eq!(
806                reason.detail_sha256(),
807                chio_core::hashing::sha256(b"secret").as_bytes()
808            );
809            let encoded = serialize(&reason);
810            assert!(!encoded.contains("secret"));
811        }
812    }
813
814    #[test]
815    fn failure_reason_digest_is_deterministic_and_private() {
816        let reason = failure(SettlementFailureCode::Rpc, "sensitive detail");
817        let restored = SettlementFailureReason::from_digest(reason.code(), *reason.detail_sha256());
818
819        assert_eq!(reason, restored);
820        assert_eq!(
821            reason.detail_sha256(),
822            chio_core::hashing::sha256(b"sensitive detail").as_bytes()
823        );
824        assert!(!serialize(&reason).contains("sensitive detail"));
825    }
826
827    #[test]
828    fn failure_code_labels_match_the_serialized_contract() {
829        let cases = [
830            SettlementFailureCode::InvalidReceiptSignature,
831            SettlementFailureCode::InvalidActionHash,
832            SettlementFailureCode::UntrustedReceiptSigner,
833            SettlementFailureCode::MalformedFinancialMetadata,
834            SettlementFailureCode::InvalidObservation,
835            SettlementFailureCode::Rpc,
836            SettlementFailureCode::InvalidInput,
837            SettlementFailureCode::InvalidDispatch,
838            SettlementFailureCode::InvalidBinding,
839            SettlementFailureCode::Unsupported,
840            SettlementFailureCode::Serialization,
841            SettlementFailureCode::Signature,
842            SettlementFailureCode::Verification,
843            SettlementFailureCode::Backend,
844        ];
845
846        for code in cases {
847            let serialized = match serde_json::to_value(code) {
848                Ok(serialized) => serialized,
849                Err(error) => panic!("failure code serialization failed: {error}"),
850            };
851            assert_eq!(
852                serialized,
853                serde_json::Value::String(code.as_str().to_string())
854            );
855            assert_eq!(SettlementFailureCode::try_from(code.as_str()), Ok(code));
856        }
857    }
858
859    #[test]
860    fn failure_code_parser_rejects_unknown_labels() {
861        for label in ["", "RPC", "rpc ", "unknown"] {
862            assert_eq!(
863                SettlementFailureCode::try_from(label),
864                Err(SettlementFailureCodeParseError)
865            );
866        }
867        assert_eq!(
868            SettlementFailureCodeParseError.to_string(),
869            "unknown settlement failure code"
870        );
871    }
872}