Skip to main content

boundgraph_core/
evidence.rs

1//! The Evidence (`ε`) — a signed verdict, not a claim.
2//!
3//! Law III: model output is a proposal, never ground truth. No state change
4//! commits on probabilistic output. A step commits only on [`Evidence`] from a
5//! deterministic checker or a statistical quorum.
6//!
7//! Three details do the real work here, and each closes a specific hole:
8//!
9//! - **[`Verdict`] has no implicit pass.** `Inconclusive` is its own case, so a
10//!   checker that timed out cannot be mistaken for one that succeeded.
11//! - **[`Evidence::inputs`] lists digests.** Evidence names exactly what was
12//!   checked, so it cannot later be re-pointed at different data. Without this
13//!   a passing verdict could be harvested and reattached to a failing patch.
14//! - **The signature covers everything else.** An unsigned verdict is
15//!   indistinguishable from a claim someone typed.
16//!
17//! This module defines the *type*. Producing signatures is US-0802, and key
18//! management is still open (`ARCHITECTURE.md` §17) — which is precisely why
19//! [`SigningKeyId`] is recorded alongside every signature: a verifier must know
20//! which key to check against without that decision being baked in here.
21
22use std::fmt;
23
24use serde::{Deserialize, Serialize};
25
26use crate::digest::Digest;
27
28/// The outcome of a check.
29///
30/// Deliberately three-valued. A two-valued verdict forces "we could not tell"
31/// into either `Pass` (unsafe) or `Fail` (blocks legitimate work), and the
32/// unsafe choice is the tempting one.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Verdict {
36    /// The check succeeded. This is the only verdict that may commit.
37    Pass,
38    /// The check failed, with a reason the repair loop can act on.
39    Fail(String),
40    /// The check could not reach a conclusion — a timeout, a crashed tool, an
41    /// unsupported input. Never commits.
42    Inconclusive(String),
43}
44
45impl Verdict {
46    /// Whether this verdict permits a commit.
47    ///
48    /// Only [`Verdict::Pass`] does. `Inconclusive` is explicitly not a pass.
49    pub fn is_pass(&self) -> bool {
50        matches!(self, Self::Pass)
51    }
52
53    /// Why the check did not pass, if it did not.
54    pub fn reason(&self) -> Option<&str> {
55        match self {
56            Self::Pass => None,
57            Self::Fail(r) | Self::Inconclusive(r) => Some(r),
58        }
59    }
60}
61
62/// Which checker produced a verdict, and at what version.
63///
64/// Versioned because a verdict is only meaningful against a specific checker:
65/// "the tests passed" means nothing without knowing which test runner said so.
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
67pub struct CheckerId {
68    /// Checker name, e.g. `pytest`, `tsc`, `diff-scope`.
69    pub name: String,
70    /// Checker version.
71    pub version: u32,
72}
73
74impl CheckerId {
75    /// Name a checker.
76    pub fn new(name: impl Into<String>, version: u32) -> Self {
77        Self {
78            name: name.into(),
79            version,
80        }
81    }
82}
83
84/// Rendered `name@version`, because a checker diagnostic that named only the
85/// checker would be ambiguous across versions — and the version is the half
86/// that makes the verdict reproducible.
87impl fmt::Display for CheckerId {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "{}@{}", self.name, self.version)
90    }
91}
92
93/// Identifies the key a signature was made with.
94///
95/// Recorded so a verifier knows which public key to check against. Whether
96/// keys are per-host, per-run, or held in an external KMS is unresolved, so
97/// this is an opaque identifier rather than an embedded key.
98#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
99#[serde(transparent)]
100pub struct SigningKeyId(pub String);
101
102impl fmt::Display for SigningKeyId {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(&self.0)
105    }
106}
107
108/// An ed25519 signature over the canonical encoding of an [`Evidence`].
109///
110/// This crate defines the type and the contract; producing and verifying
111/// signatures is the checker engine's job (US-0802).
112#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(transparent)]
114pub struct Signature(#[serde(with = "sig_bytes")] [u8; 64]);
115
116impl Signature {
117    /// Wrap raw signature bytes.
118    pub const fn from_bytes(bytes: [u8; 64]) -> Self {
119        Self(bytes)
120    }
121
122    /// The raw signature bytes.
123    pub const fn as_bytes(&self) -> &[u8; 64] {
124        &self.0
125    }
126}
127
128impl fmt::Debug for Signature {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        use fmt::Write as _;
131        let head = self.0[..6].iter().fold(String::new(), |mut s, b| {
132            let _ = write!(s, "{b:02x}");
133            s
134        });
135        write!(f, "Signature({head}…)")
136    }
137}
138
139/// How a verdict was reached.
140///
141/// The distinction matters when weighing evidence: a deterministic checker and
142/// a majority vote are not the same kind of assurance, and an auditor must be
143/// able to tell them apart.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum EvidenceKind {
147    /// A model-free checker. The strongest form.
148    Deterministic,
149    /// A statistical quorum: `k` of `n` branches agreed.
150    Quorum {
151        /// Branches run.
152        n: u8,
153        /// Branches that had to agree.
154        k: u8,
155    },
156    /// A human approved, at a gate. Always signed by a human identity.
157    Human {
158        /// Who approved.
159        identity: String,
160    },
161}
162
163/// `ε` — a signed verdict from a checker or a quorum.
164///
165/// # Example
166///
167/// ```
168/// use boundgraph_core::{CheckerId, Digest, Evidence, EvidenceKind, Signature, SigningKeyId, Verdict};
169///
170/// let patch = Digest::of(b"the patch that was checked");
171///
172/// let ev = Evidence::new(
173///     Verdict::Pass,
174///     CheckerId::new("cargo-test", 1),
175///     vec![patch],
176///     Signature::from_bytes([0u8; 64]),
177///     SigningKeyId("host-key-1".into()),
178///     EvidenceKind::Deterministic,
179/// );
180///
181/// assert!(ev.permits_commit());
182/// // Evidence names exactly what it covers, so it cannot be reattached.
183/// assert!(ev.covers(&patch));
184/// assert!(!ev.covers(&Digest::of(b"some other patch")));
185/// ```
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct Evidence {
188    /// The outcome.
189    pub verdict: Verdict,
190    /// Which checker decided, and at what version.
191    pub checker: CheckerId,
192    /// Digests of exactly what was checked.
193    ///
194    /// This is what stops evidence being re-pointed at different data: a
195    /// verdict only speaks about these inputs.
196    pub inputs: Vec<Digest>,
197    /// Signature over the canonical encoding of every other field.
198    pub signature: Signature,
199    /// Which key signed.
200    pub key: SigningKeyId,
201    /// How the verdict was reached.
202    pub kind: EvidenceKind,
203}
204
205impl Evidence {
206    /// Construct evidence.
207    ///
208    /// Inputs are sorted, so two verdicts over the same set of inputs share a
209    /// digest regardless of the order the checker happened to list them.
210    pub fn new(
211        verdict: Verdict,
212        checker: CheckerId,
213        inputs: impl IntoIterator<Item = Digest>,
214        signature: Signature,
215        key: SigningKeyId,
216        kind: EvidenceKind,
217    ) -> Self {
218        let mut inputs: Vec<Digest> = inputs.into_iter().collect();
219        inputs.sort_unstable();
220        inputs.dedup();
221        Self {
222            verdict,
223            checker,
224            inputs,
225            signature,
226            key,
227            kind,
228        }
229    }
230
231    /// Whether this evidence permits a state change to commit.
232    ///
233    /// True only for [`Verdict::Pass`].
234    pub fn permits_commit(&self) -> bool {
235        self.verdict.is_pass()
236    }
237
238    /// Whether this evidence actually speaks about the given input.
239    ///
240    /// A commit must check this. Passing evidence about a *different* artefact
241    /// is not permission to commit this one.
242    pub fn covers(&self, digest: &Digest) -> bool {
243        self.inputs.binary_search(digest).is_ok()
244    }
245
246    /// The bytes a signature is made over: everything except the signature.
247    ///
248    /// Exposed so the signer (US-0802) and any verifier compute the same
249    /// pre-image, rather than each deriving it and hoping they agree.
250    pub fn signing_payload(&self) -> Result<Vec<u8>, crate::CanonicalError> {
251        crate::to_canonical_cbor(&(
252            &self.verdict,
253            &self.checker,
254            &self.inputs,
255            &self.key,
256            &self.kind,
257        ))
258    }
259}
260
261/// Fixed 64-byte arrays are not `serde`-native; encode as a byte string.
262mod sig_bytes {
263    use serde::de::{Error as _, Unexpected};
264    use serde::{Deserializer, Serializer};
265
266    pub(super) fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
267        s.serialize_bytes(bytes)
268    }
269
270    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
271        struct V;
272        impl<'de> serde::de::Visitor<'de> for V {
273            type Value = [u8; 64];
274            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275                f.write_str("a 64-byte signature")
276            }
277            fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<[u8; 64], E> {
278                <[u8; 64]>::try_from(v)
279                    .map_err(|_| E::invalid_value(Unexpected::Bytes(v), &"exactly 64 bytes"))
280            }
281            fn visit_seq<A: serde::de::SeqAccess<'de>>(
282                self,
283                mut seq: A,
284            ) -> Result<[u8; 64], A::Error> {
285                let mut out = Vec::with_capacity(64);
286                while let Some(b) = seq.next_element::<u8>()? {
287                    out.push(b);
288                }
289                <[u8; 64]>::try_from(out.as_slice())
290                    .map_err(|_| A::Error::invalid_length(out.len(), &"exactly 64 bytes"))
291            }
292        }
293        d.deserialize_bytes(V)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::canonical::Canonical;
301
302    fn evidence(verdict: Verdict) -> Evidence {
303        Evidence::new(
304            verdict,
305            CheckerId::new("cargo-test", 1),
306            [Digest::of(b"patch")],
307            Signature::from_bytes([7u8; 64]),
308            SigningKeyId("host-key-1".into()),
309            EvidenceKind::Deterministic,
310        )
311    }
312
313    #[test]
314    fn carries_verdict_checker_inputs_signature_and_kind() {
315        let e = evidence(Verdict::Pass);
316        assert_eq!(e.verdict, Verdict::Pass);
317        assert_eq!(e.checker, CheckerId::new("cargo-test", 1));
318        assert_eq!(e.inputs, vec![Digest::of(b"patch")]);
319        assert_eq!(e.signature, Signature::from_bytes([7u8; 64]));
320        assert_eq!(e.kind, EvidenceKind::Deterministic);
321    }
322
323    #[test]
324    fn only_pass_permits_a_commit() {
325        assert!(evidence(Verdict::Pass).permits_commit());
326        assert!(!evidence(Verdict::Fail("2 tests failed".into())).permits_commit());
327        assert!(!evidence(Verdict::Inconclusive("timed out".into())).permits_commit());
328    }
329
330    #[test]
331    fn inconclusive_is_not_an_implicit_pass() {
332        let e = evidence(Verdict::Inconclusive("checker crashed".into()));
333        assert!(!e.permits_commit());
334        assert_eq!(e.verdict.reason(), Some("checker crashed"));
335    }
336
337    #[test]
338    fn evidence_cannot_be_repointed_at_other_data() {
339        let e = evidence(Verdict::Pass);
340        assert!(e.covers(&Digest::of(b"patch")));
341        assert!(
342            !e.covers(&Digest::of(b"a different patch")),
343            "passing evidence must not vouch for something it never saw"
344        );
345    }
346
347    #[test]
348    fn input_order_does_not_change_identity() {
349        let a = Evidence::new(
350            Verdict::Pass,
351            CheckerId::new("c", 1),
352            [Digest::of(b"x"), Digest::of(b"y")],
353            Signature::from_bytes([0u8; 64]),
354            SigningKeyId("k".into()),
355            EvidenceKind::Deterministic,
356        );
357        let b = Evidence::new(
358            Verdict::Pass,
359            CheckerId::new("c", 1),
360            [Digest::of(b"y"), Digest::of(b"x")],
361            Signature::from_bytes([0u8; 64]),
362            SigningKeyId("k".into()),
363            EvidenceKind::Deterministic,
364        );
365        assert_eq!(a.digest().unwrap(), b.digest().unwrap());
366        // …and `covers` still works after the sort.
367        assert!(a.covers(&Digest::of(b"x")));
368        assert!(a.covers(&Digest::of(b"y")));
369    }
370
371    #[test]
372    fn ids_render_for_a_diagnostic() {
373        assert_eq!(
374            CheckerId::new("json-schema", 2).to_string(),
375            "json-schema@2"
376        );
377        assert_eq!(SigningKeyId("host-key-1".into()).to_string(), "host-key-1");
378    }
379
380    #[test]
381    fn kinds_are_distinguishable() {
382        assert_ne!(
383            EvidenceKind::Deterministic,
384            EvidenceKind::Quorum { n: 3, k: 2 }
385        );
386        assert_ne!(
387            EvidenceKind::Quorum { n: 3, k: 2 },
388            EvidenceKind::Human {
389                identity: "alex".into()
390            }
391        );
392    }
393
394    #[test]
395    fn signing_payload_excludes_the_signature_but_covers_everything_else() {
396        let base = evidence(Verdict::Pass);
397
398        // A different signature over the same claim has the same pre-image.
399        let mut resigned = base.clone();
400        resigned.signature = Signature::from_bytes([9u8; 64]);
401        assert_eq!(
402            base.signing_payload().unwrap(),
403            resigned.signing_payload().unwrap()
404        );
405
406        // Changing any covered field changes the pre-image.
407        let flipped = evidence(Verdict::Fail("no".into()));
408        assert_ne!(
409            base.signing_payload().unwrap(),
410            flipped.signing_payload().unwrap()
411        );
412
413        let mut rekeyed = base.clone();
414        rekeyed.key = SigningKeyId("other-key".into());
415        assert_ne!(
416            base.signing_payload().unwrap(),
417            rekeyed.signing_payload().unwrap()
418        );
419
420        let mut recovered = base.clone();
421        recovered.inputs = vec![Digest::of(b"other")];
422        assert_ne!(
423            base.signing_payload().unwrap(),
424            recovered.signing_payload().unwrap()
425        );
426    }
427
428    #[test]
429    fn signature_debug_does_not_dump_all_64_bytes() {
430        let d = format!("{:?}", Signature::from_bytes([0xab; 64]));
431        assert!(d.len() < 40, "debug output should be abbreviated: {d}");
432    }
433
434    #[test]
435    fn round_trips_through_canonical_cbor() {
436        let e = evidence(Verdict::Fail("2 tests failed".into()));
437        let bytes = crate::to_canonical_cbor(&e).unwrap();
438        let back: Evidence = crate::from_canonical_cbor(&bytes).unwrap();
439        assert_eq!(e, back);
440    }
441}