Skip to main content

cortex_core/
proof.rs

1//! Proof closure state for ledger, lineage, and authority verification.
2//!
3//! This module is intentionally pure shape logic for `cortex-core`: no I/O,
4//! no hashing, no signature verification, and no ledger reads. Verifier crates
5//! supply the observed edges and failures; these types preserve the result
6//! without allowing a partial proof to be accidentally reported as full.
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::policy::{compose_policy_outcomes, PolicyContribution, PolicyDecision, PolicyOutcome};
12use crate::{CoreError, CoreResult};
13
14/// Overall state of a proof closure check.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum ProofState {
18    /// Every required edge was present and verified.
19    FullChainVerified,
20    /// Some required edge is missing or unresolved, but no contradiction was
21    /// observed.
22    Partial,
23    /// At least one required edge was observed and found invalid.
24    Broken,
25}
26
27/// Kind of edge participating in a proof closure.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "snake_case")]
30pub enum ProofEdgeKind {
31    /// Event-to-event hash-chain continuity.
32    HashChain,
33    /// Payload or envelope signature verification.
34    Signature,
35    /// Identity rotation continuity.
36    IdentityRotation,
37    /// External anchor binding for a ledger tip.
38    ExternalAnchor,
39    /// Summary, memory, or context lineage closure.
40    LineageClosure,
41    /// Authority fold or effective-trust propagation.
42    AuthorityFold,
43    /// Context pack linkage back to selected source artifacts.
44    ContextPackLink,
45}
46
47/// A verified proof edge.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
49pub struct ProofEdge {
50    /// Edge category.
51    pub kind: ProofEdgeKind,
52    /// Stable source reference for the edge.
53    pub from_ref: String,
54    /// Stable target reference for the edge.
55    pub to_ref: String,
56    /// Optional evidence reference such as a signature id, anchor id, or audit
57    /// row id.
58    pub evidence_ref: Option<String>,
59}
60
61impl ProofEdge {
62    /// Construct a verified proof edge.
63    #[must_use]
64    pub fn new(
65        kind: ProofEdgeKind,
66        from_ref: impl Into<String>,
67        to_ref: impl Into<String>,
68    ) -> Self {
69        Self {
70            kind,
71            from_ref: from_ref.into(),
72            to_ref: to_ref.into(),
73            evidence_ref: None,
74        }
75    }
76
77    /// Attach an evidence reference.
78    #[must_use]
79    pub fn with_evidence_ref(mut self, evidence_ref: impl Into<String>) -> Self {
80        self.evidence_ref = Some(evidence_ref.into());
81        self
82    }
83}
84
85/// Failure category for a proof edge.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
87#[serde(rename_all = "snake_case")]
88pub enum ProofEdgeFailure {
89    /// The expected edge was not found.
90    Missing,
91    /// The verifier could not resolve enough data to decide.
92    Unresolved,
93    /// The edge exists but does not match the expected hash or reference.
94    Mismatch,
95    /// The signature edge failed cryptographic verification.
96    InvalidSignature,
97    /// The external anchor does not bind the expected ledger tip.
98    AnchorMismatch,
99    /// Authority or effective-trust recomputation disagreed with the cached
100    /// value.
101    AuthorityMismatch,
102}
103
104impl ProofEdgeFailure {
105    /// Whether this failure proves the chain is broken rather than merely
106    /// incomplete.
107    #[must_use]
108    pub const fn is_broken(self) -> bool {
109        match self {
110            Self::Missing | Self::Unresolved => false,
111            Self::Mismatch
112            | Self::InvalidSignature
113            | Self::AnchorMismatch
114            | Self::AuthorityMismatch => true,
115        }
116    }
117}
118
119/// A missing, unresolved, or invalid proof edge.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121pub struct FailingEdge {
122    /// Edge category.
123    pub kind: ProofEdgeKind,
124    /// Stable source reference for the edge.
125    pub from_ref: String,
126    /// Optional target reference when known.
127    pub to_ref: Option<String>,
128    /// Failure category.
129    pub failure: ProofEdgeFailure,
130    /// Operator-facing explanation. This should name the failed invariant, not
131    /// contain secret material.
132    pub reason: String,
133}
134
135impl FailingEdge {
136    /// Construct a missing-edge failure.
137    #[must_use]
138    pub fn missing(
139        kind: ProofEdgeKind,
140        from_ref: impl Into<String>,
141        reason: impl Into<String>,
142    ) -> Self {
143        Self {
144            kind,
145            from_ref: from_ref.into(),
146            to_ref: None,
147            failure: ProofEdgeFailure::Missing,
148            reason: reason.into(),
149        }
150    }
151
152    /// Construct an unresolved-edge failure.
153    #[must_use]
154    pub fn unresolved(
155        kind: ProofEdgeKind,
156        from_ref: impl Into<String>,
157        reason: impl Into<String>,
158    ) -> Self {
159        Self {
160            kind,
161            from_ref: from_ref.into(),
162            to_ref: None,
163            failure: ProofEdgeFailure::Unresolved,
164            reason: reason.into(),
165        }
166    }
167
168    /// Construct a broken-edge failure.
169    #[must_use]
170    pub fn broken(
171        kind: ProofEdgeKind,
172        from_ref: impl Into<String>,
173        to_ref: impl Into<String>,
174        failure: ProofEdgeFailure,
175        reason: impl Into<String>,
176    ) -> Self {
177        debug_assert!(failure.is_broken());
178        let failure = if failure.is_broken() {
179            failure
180        } else {
181            ProofEdgeFailure::Mismatch
182        };
183
184        Self {
185            kind,
186            from_ref: from_ref.into(),
187            to_ref: Some(to_ref.into()),
188            failure,
189            reason: reason.into(),
190        }
191    }
192
193    /// Whether this failed edge proves a broken proof closure.
194    #[must_use]
195    pub const fn is_broken(&self) -> bool {
196        self.failure.is_broken()
197    }
198}
199
200/// Closure report for a proof graph.
201///
202/// The `state` field is private by design. Callers either create a full report
203/// with [`ProofClosureReport::full_chain_verified`] or let
204/// [`ProofClosureReport::from_edges`] compute the weakest truthful state from
205/// the supplied failures.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
207pub struct ProofClosureReport {
208    #[serde(rename = "proof_state")]
209    state: ProofState,
210    verified_edges: Vec<ProofEdge>,
211    failing_edges: Vec<FailingEdge>,
212}
213
214impl ProofClosureReport {
215    /// Construct a report that has no failing edges.
216    #[must_use]
217    pub fn full_chain_verified(verified_edges: Vec<ProofEdge>) -> Self {
218        Self {
219            state: ProofState::FullChainVerified,
220            verified_edges,
221            failing_edges: Vec::new(),
222        }
223    }
224
225    /// Construct a report and compute state from the supplied failing edges.
226    ///
227    /// Any broken failure makes the report [`ProofState::Broken`]. Any
228    /// non-empty missing or unresolved failure list makes it
229    /// [`ProofState::Partial`]. Only an empty failure list can be full.
230    #[must_use]
231    pub fn from_edges(verified_edges: Vec<ProofEdge>, failing_edges: Vec<FailingEdge>) -> Self {
232        let state = classify_failures(&failing_edges);
233        Self {
234            state,
235            verified_edges,
236            failing_edges,
237        }
238    }
239
240    /// Current proof state.
241    #[must_use]
242    pub const fn state(&self) -> ProofState {
243        self.state
244    }
245
246    /// Verified edges.
247    #[must_use]
248    pub fn verified_edges(&self) -> &[ProofEdge] {
249        &self.verified_edges
250    }
251
252    /// Missing, unresolved, or invalid edges.
253    #[must_use]
254    pub fn failing_edges(&self) -> &[FailingEdge] {
255        &self.failing_edges
256    }
257
258    /// Whether the report is fully verified.
259    #[must_use]
260    pub const fn is_full_chain_verified(&self) -> bool {
261        matches!(self.state, ProofState::FullChainVerified)
262    }
263
264    /// Whether the report has at least one broken edge.
265    #[must_use]
266    pub const fn is_broken(&self) -> bool {
267        matches!(self.state, ProofState::Broken)
268    }
269
270    /// Add a failing edge and recompute the weakest truthful state.
271    pub fn push_failing_edge(&mut self, edge: FailingEdge) {
272        self.failing_edges.push(edge);
273        self.state = classify_failures(&self.failing_edges);
274    }
275
276    /// Add a failing edge and return the updated report.
277    #[must_use]
278    pub fn with_failing_edge(mut self, edge: FailingEdge) -> Self {
279        self.push_failing_edge(edge);
280        self
281    }
282
283    /// Derive the ADR 0026 policy decision for this proof closure.
284    #[must_use]
285    pub fn policy_decision(&self) -> PolicyDecision {
286        let outcome = match self.state {
287            ProofState::FullChainVerified => PolicyOutcome::Allow,
288            ProofState::Partial => PolicyOutcome::Quarantine,
289            ProofState::Broken => PolicyOutcome::Reject,
290        };
291        let reason = match self.state {
292            ProofState::FullChainVerified => "proof closure is fully verified",
293            ProofState::Partial => {
294                "proof closure is partial and cannot be treated as clean authority"
295            }
296            ProofState::Broken => "proof closure is broken and fails closed",
297        };
298        compose_policy_outcomes(
299            vec![
300                PolicyContribution::new("proof_closure.state", outcome, reason)
301                    .expect("static policy contribution is valid"),
302            ],
303            None,
304        )
305    }
306
307    /// Fail closed before this proof report is consumed as current authority.
308    pub fn require_current_use_allowed(&self) -> CoreResult<()> {
309        let policy = self.policy_decision();
310        match policy.final_outcome {
311            PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
312                Err(CoreError::Validation(format!(
313                    "proof closure current use blocked by policy outcome {:?}",
314                    policy.final_outcome
315                )))
316            }
317            PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
318        }
319    }
320}
321
322const fn classify_failures(failing_edges: &[FailingEdge]) -> ProofState {
323    if failing_edges.is_empty() {
324        return ProofState::FullChainVerified;
325    }
326
327    let mut i = 0;
328    while i < failing_edges.len() {
329        if failing_edges[i].is_broken() {
330            return ProofState::Broken;
331        }
332        i += 1;
333    }
334
335    ProofState::Partial
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn edge() -> ProofEdge {
343        ProofEdge::new(ProofEdgeKind::HashChain, "evt_a", "evt_b").with_evidence_ref("hash_ab")
344    }
345
346    #[test]
347    fn full_report_cannot_carry_failures() {
348        let report = ProofClosureReport::full_chain_verified(vec![edge()]);
349        assert_eq!(report.state(), ProofState::FullChainVerified);
350        assert!(report.is_full_chain_verified());
351        assert!(report.failing_edges().is_empty());
352    }
353
354    #[test]
355    fn missing_edge_downgrades_to_partial() {
356        let report = ProofClosureReport::from_edges(
357            vec![edge()],
358            vec![FailingEdge::missing(
359                ProofEdgeKind::ExternalAnchor,
360                "tip_1",
361                "anchor not available",
362            )],
363        );
364
365        assert_eq!(report.state(), ProofState::Partial);
366        assert!(!report.is_full_chain_verified());
367        assert!(!report.is_broken());
368    }
369
370    #[test]
371    fn broken_edge_downgrades_to_broken() {
372        let report = ProofClosureReport::from_edges(
373            vec![edge()],
374            vec![FailingEdge::broken(
375                ProofEdgeKind::Signature,
376                "evt_a",
377                "sig_a",
378                ProofEdgeFailure::InvalidSignature,
379                "signature verification failed",
380            )],
381        );
382
383        assert_eq!(report.state(), ProofState::Broken);
384        assert!(report.is_broken());
385    }
386
387    #[test]
388    fn adding_failure_recomputes_state() {
389        let mut report = ProofClosureReport::full_chain_verified(vec![edge()]);
390        report.push_failing_edge(FailingEdge::unresolved(
391            ProofEdgeKind::LineageClosure,
392            "mem_a",
393            "source event not loaded",
394        ));
395
396        assert_eq!(report.state(), ProofState::Partial);
397        assert!(!report.is_full_chain_verified());
398    }
399
400    #[test]
401    fn proof_state_wire_strings_are_stable() {
402        assert_eq!(
403            serde_json::to_value(ProofState::FullChainVerified).unwrap(),
404            serde_json::json!("full_chain_verified")
405        );
406        assert_eq!(
407            serde_json::to_value(ProofState::Partial).unwrap(),
408            serde_json::json!("partial")
409        );
410        assert_eq!(
411            serde_json::to_value(ProofState::Broken).unwrap(),
412            serde_json::json!("broken")
413        );
414    }
415
416    #[test]
417    fn proof_report_serializes_proof_state_field() {
418        let report = ProofClosureReport::from_edges(
419            Vec::new(),
420            vec![FailingEdge::missing(
421                ProofEdgeKind::ExternalAnchor,
422                "tip_1",
423                "anchor not available",
424            )],
425        );
426        let serialized = serde_json::to_value(report).unwrap();
427
428        assert_eq!(serialized["proof_state"], serde_json::json!("partial"));
429        assert!(serialized.get("state").is_none());
430    }
431
432    #[test]
433    fn proof_state_derives_policy_decision() {
434        let full = ProofClosureReport::full_chain_verified(Vec::new());
435        let partial = ProofClosureReport::from_edges(
436            Vec::new(),
437            vec![FailingEdge::missing(
438                ProofEdgeKind::LineageClosure,
439                "memory:mem_01",
440                "source missing",
441            )],
442        );
443        let broken = ProofClosureReport::from_edges(
444            Vec::new(),
445            vec![FailingEdge::broken(
446                ProofEdgeKind::HashChain,
447                "event:a",
448                "event:b",
449                ProofEdgeFailure::Mismatch,
450                "hash mismatch",
451            )],
452        );
453
454        assert_eq!(full.policy_decision().final_outcome, PolicyOutcome::Allow);
455        assert_eq!(
456            partial.policy_decision().final_outcome,
457            PolicyOutcome::Quarantine
458        );
459        assert_eq!(
460            broken.policy_decision().final_outcome,
461            PolicyOutcome::Reject
462        );
463    }
464
465    #[test]
466    fn partial_or_broken_proof_fails_closed_for_current_use() {
467        let partial = ProofClosureReport::from_edges(
468            Vec::new(),
469            vec![FailingEdge::missing(
470                ProofEdgeKind::LineageClosure,
471                "memory:mem_01",
472                "source missing",
473            )],
474        );
475        let broken = ProofClosureReport::from_edges(
476            Vec::new(),
477            vec![FailingEdge::broken(
478                ProofEdgeKind::HashChain,
479                "event:a",
480                "event:b",
481                ProofEdgeFailure::Mismatch,
482                "hash mismatch",
483            )],
484        );
485
486        assert!(partial.require_current_use_allowed().is_err());
487        assert!(broken.require_current_use_allowed().is_err());
488        ProofClosureReport::full_chain_verified(Vec::new())
489            .require_current_use_allowed()
490            .expect("full chain proof supports current use");
491    }
492}