Skip to main content

cortex_core/
claims.rs

1//! Claim ceilings and reportability metadata.
2//!
3//! Cortex must distinguish local development mechanics from signed,
4//! externally anchored, or authority-grade claims. This module provides the
5//! small lattice used by higher layers to downgrade mixed evidence to the
6//! weakest truthful claim.
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::proof::ProofState;
12
13/// Runtime mode that bounds what Cortex may truthfully claim.
14#[derive(
15    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum RuntimeMode {
19    /// Runtime mode was not supplied or could not be verified.
20    Unknown,
21    /// Development-only execution. No durable authority claim should escape.
22    Dev,
23    /// Remote API call (e.g. Anthropic, external hosted model) whose response
24    /// is unsigned and whose provenance cannot be locally verified. Trust
25    /// ceiling is lower than `LocalUnsigned` because the execution boundary
26    /// is outside the operator's machine. See ADR 0037 and ADR 0048 prep.
27    RemoteUnsigned,
28    /// Local unsigned append or local-only metadata.
29    LocalUnsigned,
30    /// Signed local ledger evidence is available.
31    SignedLocalLedger,
32    /// Signed evidence is bound to an external anchor surface.
33    ExternallyAnchored,
34    /// Authority-grade mode: signed, anchored, policy-gated, and suitable for
35    /// high-authority reporting.
36    AuthorityGrade,
37}
38
39impl RuntimeMode {
40    /// Highest claim ceiling this runtime mode can support.
41    #[must_use]
42    pub const fn claim_ceiling(self) -> ClaimCeiling {
43        match self {
44            Self::Unknown => ClaimCeiling::DevOnly,
45            Self::Dev => ClaimCeiling::DevOnly,
46            Self::RemoteUnsigned => ClaimCeiling::LocalUnsigned,
47            Self::LocalUnsigned => ClaimCeiling::LocalUnsigned,
48            Self::SignedLocalLedger => ClaimCeiling::SignedLocalLedger,
49            Self::ExternallyAnchored => ClaimCeiling::ExternallyAnchored,
50            Self::AuthorityGrade => ClaimCeiling::AuthorityGrade,
51        }
52    }
53}
54
55/// Authority class of the evidence supporting a claim.
56#[derive(
57    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
58)]
59#[serde(rename_all = "snake_case")]
60pub enum AuthorityClass {
61    /// Source may be observed but must not influence durable authority.
62    Untrusted,
63    /// Derived artifact such as a summary or reflection candidate.
64    Derived,
65    /// Audit-visible observed source with local influence only.
66    Observed,
67    /// Operator-approved source for bounded automation.
68    Verified,
69    /// Human root of trust or policy-equivalent authority for this deployment.
70    Operator,
71}
72
73/// Proof state as it participates in claim reporting.
74///
75/// `Unknown` is explicit because ADR 0037 forbids silently treating absent
76/// proof closure as local unsigned authority.
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
79)]
80#[serde(rename_all = "snake_case")]
81pub enum ClaimProofState {
82    /// No proof closure was supplied.
83    Unknown,
84    /// The proof closure is broken and can support diagnostics only.
85    Broken,
86    /// The proof closure is incomplete but no broken edge was observed.
87    Partial,
88    /// Every required proof edge for the claim passed.
89    FullChainVerified,
90}
91
92impl ClaimProofState {
93    /// Highest claim ceiling this proof state can support.
94    #[must_use]
95    pub const fn claim_ceiling(self) -> ClaimCeiling {
96        match self {
97            Self::Unknown | Self::Broken => ClaimCeiling::DevOnly,
98            Self::Partial => ClaimCeiling::LocalUnsigned,
99            Self::FullChainVerified => ClaimCeiling::AuthorityGrade,
100        }
101    }
102}
103
104impl From<ProofState> for ClaimProofState {
105    fn from(value: ProofState) -> Self {
106        match value {
107            ProofState::FullChainVerified => Self::FullChainVerified,
108            ProofState::Partial => Self::Partial,
109            ProofState::Broken => Self::Broken,
110        }
111    }
112}
113
114impl AuthorityClass {
115    /// Highest claim ceiling this authority class can support.
116    #[must_use]
117    pub const fn claim_ceiling(self) -> ClaimCeiling {
118        match self {
119            Self::Untrusted => ClaimCeiling::DevOnly,
120            Self::Derived | Self::Observed => ClaimCeiling::LocalUnsigned,
121            Self::Verified => ClaimCeiling::SignedLocalLedger,
122            Self::Operator => ClaimCeiling::AuthorityGrade,
123        }
124    }
125
126    /// Weakest authority class from an iterator.
127    #[must_use]
128    pub fn weakest<I>(classes: I) -> Option<Self>
129    where
130        I: IntoIterator<Item = Self>,
131    {
132        classes.into_iter().min()
133    }
134}
135
136/// Maximum truthful reporting level for a claim.
137#[derive(
138    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
139)]
140#[serde(rename_all = "snake_case")]
141pub enum ClaimCeiling {
142    /// Development-only; not reportable as durable evidence.
143    DevOnly,
144    /// Local unsigned mechanics only.
145    LocalUnsigned,
146    /// Signed local ledger claim.
147    SignedLocalLedger,
148    /// Externally anchored ledger claim.
149    ExternallyAnchored,
150    /// Authority-grade claim suitable for high-authority reporting.
151    AuthorityGrade,
152}
153
154impl ClaimCeiling {
155    /// Weakest ceiling from an iterator.
156    #[must_use]
157    pub fn weakest<I>(ceilings: I) -> Option<Self>
158    where
159        I: IntoIterator<Item = Self>,
160    {
161        ceilings.into_iter().min()
162    }
163
164    /// Weaken this ceiling to the weaker of two ceilings.
165    #[must_use]
166    pub fn mix_to_weakest(self, other: Self) -> Self {
167        self.min(other)
168    }
169}
170
171/// Metadata for a claim that may be shown, exported, or used as authority.
172///
173/// `effective_ceiling` is private and always recomputed from runtime mode,
174/// authority class, and requested ceiling. Callers can ask for a high ceiling,
175/// but they cannot construct a reportable claim above its weakest evidence.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177pub struct ReportableClaim {
178    claim: String,
179    runtime_mode: RuntimeMode,
180    authority_class: AuthorityClass,
181    proof_state: ClaimProofState,
182    requested_ceiling: ClaimCeiling,
183    effective_ceiling: ClaimCeiling,
184    downgrade_reasons: Vec<String>,
185}
186
187impl ReportableClaim {
188    /// Construct claim metadata with the effective ceiling clamped to the
189    /// weakest supporting signal.
190    #[must_use]
191    pub fn new(
192        claim: impl Into<String>,
193        runtime_mode: RuntimeMode,
194        authority_class: AuthorityClass,
195        proof_state: ClaimProofState,
196        requested_ceiling: ClaimCeiling,
197    ) -> Self {
198        let mut downgrade_reasons = Vec::new();
199        let effective_ceiling = effective_ceiling(
200            runtime_mode,
201            authority_class,
202            proof_state,
203            requested_ceiling,
204        );
205
206        if effective_ceiling < requested_ceiling {
207            downgrade_reasons.push(format!(
208                "requested ceiling {requested_ceiling:?} downgraded to {effective_ceiling:?}"
209            ));
210        }
211        if proof_state != ClaimProofState::FullChainVerified {
212            downgrade_reasons.push(format!(
213                "proof state {proof_state:?} limits authority claims"
214            ));
215        }
216
217        Self {
218            claim: claim.into(),
219            runtime_mode,
220            authority_class,
221            proof_state,
222            requested_ceiling,
223            effective_ceiling,
224            downgrade_reasons,
225        }
226    }
227
228    /// Claim text.
229    #[must_use]
230    pub fn claim(&self) -> &str {
231        &self.claim
232    }
233
234    /// Runtime mode used to evaluate this claim.
235    #[must_use]
236    pub const fn runtime_mode(&self) -> RuntimeMode {
237        self.runtime_mode
238    }
239
240    /// Authority class used to evaluate this claim.
241    #[must_use]
242    pub const fn authority_class(&self) -> AuthorityClass {
243        self.authority_class
244    }
245
246    /// Proof state used to evaluate this claim.
247    #[must_use]
248    pub const fn proof_state(&self) -> ClaimProofState {
249        self.proof_state
250    }
251
252    /// Ceiling requested by the producer.
253    #[must_use]
254    pub const fn requested_ceiling(&self) -> ClaimCeiling {
255        self.requested_ceiling
256    }
257
258    /// Effective ceiling after weakest-link downgrade.
259    #[must_use]
260    pub const fn effective_ceiling(&self) -> ClaimCeiling {
261        self.effective_ceiling
262    }
263
264    /// Reasons this claim was downgraded.
265    #[must_use]
266    pub fn downgrade_reasons(&self) -> &[String] {
267        &self.downgrade_reasons
268    }
269
270    /// Return a copy downgraded to the supplied ceiling.
271    #[must_use]
272    pub fn downgraded_to(mut self, ceiling: ClaimCeiling, reason: impl Into<String>) -> Self {
273        self.requested_ceiling = self.requested_ceiling.min(ceiling);
274        self.effective_ceiling = effective_ceiling(
275            self.runtime_mode,
276            self.authority_class,
277            self.proof_state,
278            self.requested_ceiling,
279        );
280        self.downgrade_reasons.push(reason.into());
281        self
282    }
283
284    /// Mix this claim with another claim and return the weakest truthful
285    /// metadata for the combined assertion.
286    #[must_use]
287    pub fn mix_to_weakest(mut self, other: &Self, claim: impl Into<String>) -> Self {
288        self.claim = claim.into();
289        self.runtime_mode = self.runtime_mode.min(other.runtime_mode);
290        self.authority_class = self.authority_class.min(other.authority_class);
291        self.proof_state = self.proof_state.min(other.proof_state);
292        self.requested_ceiling = self.requested_ceiling.min(other.requested_ceiling);
293        self.effective_ceiling = self.effective_ceiling.min(other.effective_ceiling);
294        self.downgrade_reasons
295            .extend(other.downgrade_reasons.iter().cloned());
296        self.downgrade_reasons
297            .push("mixed claim downgraded to weakest contributing claim".into());
298        self
299    }
300}
301
302/// Compute the effective ceiling for the supplied signals.
303#[must_use]
304pub fn effective_ceiling(
305    runtime_mode: RuntimeMode,
306    authority_class: AuthorityClass,
307    proof_state: ClaimProofState,
308    requested_ceiling: ClaimCeiling,
309) -> ClaimCeiling {
310    ClaimCeiling::weakest([
311        runtime_mode.claim_ceiling(),
312        authority_class.claim_ceiling(),
313        proof_state.claim_ceiling(),
314        requested_ceiling,
315    ])
316    .expect("fixed-size ceiling set is non-empty")
317}
318
319/// Compute the weakest authority class for mixed evidence.
320#[must_use]
321pub fn mix_authority_to_weakest<I>(classes: I) -> Option<AuthorityClass>
322where
323    I: IntoIterator<Item = AuthorityClass>,
324{
325    AuthorityClass::weakest(classes)
326}
327
328/// Compute the weakest claim ceiling for mixed evidence.
329#[must_use]
330pub fn mix_claims_to_weakest<I>(ceilings: I) -> Option<ClaimCeiling>
331where
332    I: IntoIterator<Item = ClaimCeiling>,
333{
334    ClaimCeiling::weakest(ceilings)
335}
336
337/// Compute the weakest effective ceiling across reportable claims.
338#[must_use]
339pub fn mix_reportable_claims_to_weakest<'a, I>(claims: I) -> Option<ClaimCeiling>
340where
341    I: IntoIterator<Item = &'a ReportableClaim>,
342{
343    claims
344        .into_iter()
345        .map(ReportableClaim::effective_ceiling)
346        .min()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn runtime_mode_caps_claim_ceiling() {
355        assert_eq!(RuntimeMode::Unknown.claim_ceiling(), ClaimCeiling::DevOnly);
356        assert_eq!(RuntimeMode::Dev.claim_ceiling(), ClaimCeiling::DevOnly);
357        assert_eq!(
358            RuntimeMode::RemoteUnsigned.claim_ceiling(),
359            ClaimCeiling::LocalUnsigned
360        );
361        assert_eq!(
362            RuntimeMode::LocalUnsigned.claim_ceiling(),
363            ClaimCeiling::LocalUnsigned
364        );
365        assert_eq!(
366            RuntimeMode::SignedLocalLedger.claim_ceiling(),
367            ClaimCeiling::SignedLocalLedger
368        );
369        assert_eq!(
370            RuntimeMode::ExternallyAnchored.claim_ceiling(),
371            ClaimCeiling::ExternallyAnchored
372        );
373        assert_eq!(
374            RuntimeMode::AuthorityGrade.claim_ceiling(),
375            ClaimCeiling::AuthorityGrade
376        );
377    }
378
379    #[test]
380    fn authority_class_caps_claim_ceiling() {
381        assert_eq!(
382            AuthorityClass::Untrusted.claim_ceiling(),
383            ClaimCeiling::DevOnly
384        );
385        assert_eq!(
386            AuthorityClass::Derived.claim_ceiling(),
387            ClaimCeiling::LocalUnsigned
388        );
389        assert_eq!(
390            AuthorityClass::Observed.claim_ceiling(),
391            ClaimCeiling::LocalUnsigned
392        );
393        assert_eq!(
394            AuthorityClass::Verified.claim_ceiling(),
395            ClaimCeiling::SignedLocalLedger
396        );
397        assert_eq!(
398            AuthorityClass::Operator.claim_ceiling(),
399            ClaimCeiling::AuthorityGrade
400        );
401    }
402
403    #[test]
404    fn reportable_claim_clamps_to_weakest_signal() {
405        let claim = ReportableClaim::new(
406            "phase 2 mechanics verified",
407            RuntimeMode::LocalUnsigned,
408            AuthorityClass::Operator,
409            ClaimProofState::FullChainVerified,
410            ClaimCeiling::AuthorityGrade,
411        );
412
413        assert_eq!(claim.effective_ceiling(), ClaimCeiling::LocalUnsigned);
414        assert!(!claim.downgrade_reasons().is_empty());
415    }
416
417    #[test]
418    fn verified_source_cannot_lift_dev_runtime() {
419        let claim = ReportableClaim::new(
420            "trusted run history",
421            RuntimeMode::Dev,
422            AuthorityClass::Verified,
423            ClaimProofState::FullChainVerified,
424            ClaimCeiling::SignedLocalLedger,
425        );
426
427        assert_eq!(claim.effective_ceiling(), ClaimCeiling::DevOnly);
428    }
429
430    #[test]
431    fn proof_state_caps_claim_ceiling() {
432        let partial = ReportableClaim::new(
433            "operator action observed",
434            RuntimeMode::AuthorityGrade,
435            AuthorityClass::Operator,
436            ClaimProofState::Partial,
437            ClaimCeiling::AuthorityGrade,
438        );
439        let broken = ReportableClaim::new(
440            "trusted run history",
441            RuntimeMode::AuthorityGrade,
442            AuthorityClass::Operator,
443            ClaimProofState::Broken,
444            ClaimCeiling::AuthorityGrade,
445        );
446        let unknown = ReportableClaim::new(
447            "export-ready evidence",
448            RuntimeMode::AuthorityGrade,
449            AuthorityClass::Operator,
450            ClaimProofState::Unknown,
451            ClaimCeiling::AuthorityGrade,
452        );
453
454        assert_eq!(partial.effective_ceiling(), ClaimCeiling::LocalUnsigned);
455        assert_eq!(broken.effective_ceiling(), ClaimCeiling::DevOnly);
456        assert_eq!(unknown.effective_ceiling(), ClaimCeiling::DevOnly);
457    }
458
459    #[test]
460    fn mixed_claims_use_weakest_effective_ceiling() {
461        let strong = ReportableClaim::new(
462            "anchored ledger tip",
463            RuntimeMode::ExternallyAnchored,
464            AuthorityClass::Operator,
465            ClaimProofState::FullChainVerified,
466            ClaimCeiling::ExternallyAnchored,
467        );
468        let weak = ReportableClaim::new(
469            "development ledger append",
470            RuntimeMode::LocalUnsigned,
471            AuthorityClass::Observed,
472            ClaimProofState::Partial,
473            ClaimCeiling::AuthorityGrade,
474        );
475
476        assert_eq!(
477            mix_reportable_claims_to_weakest([&strong, &weak]),
478            Some(ClaimCeiling::LocalUnsigned)
479        );
480
481        let mixed = strong.mix_to_weakest(&weak, "combined claim");
482        assert_eq!(mixed.effective_ceiling(), ClaimCeiling::LocalUnsigned);
483    }
484
485    #[test]
486    fn runtime_mode_wire_strings_are_stable() {
487        assert_eq!(
488            serde_json::to_value(RuntimeMode::Unknown).unwrap(),
489            serde_json::json!("unknown")
490        );
491        assert_eq!(
492            serde_json::to_value(RuntimeMode::Dev).unwrap(),
493            serde_json::json!("dev")
494        );
495        assert_eq!(
496            serde_json::to_value(RuntimeMode::RemoteUnsigned).unwrap(),
497            serde_json::json!("remote_unsigned")
498        );
499        assert_eq!(
500            serde_json::to_value(RuntimeMode::LocalUnsigned).unwrap(),
501            serde_json::json!("local_unsigned")
502        );
503        assert_eq!(
504            serde_json::to_value(RuntimeMode::SignedLocalLedger).unwrap(),
505            serde_json::json!("signed_local_ledger")
506        );
507        assert_eq!(
508            serde_json::to_value(RuntimeMode::ExternallyAnchored).unwrap(),
509            serde_json::json!("externally_anchored")
510        );
511        assert_eq!(
512            serde_json::to_value(RuntimeMode::AuthorityGrade).unwrap(),
513            serde_json::json!("authority_grade")
514        );
515    }
516}