Skip to main content

boundgraph_check/
checker.rs

1//! The contract every checker meets, and the machinery that makes it hold.
2//!
3//! # What a checker is for
4//!
5//! Law III: model output is a proposal, never ground truth. Something has to
6//! decide whether the proposal may commit, and that something must not itself
7//! be a model — otherwise the guarantee is circular and the whole engine is an
8//! elaborate way of asking a language model twice.
9//!
10//! A [`Checker`] is that decider. It takes the proposal ([`WorkOrder`]) and what
11//! actually happened ([`Condensate`]), and returns a verdict. It is the only
12//! thing in BoundGraph whose answer can cause a state change.
13//!
14//! # Why `check` is a provided method
15//!
16//! An implementation writes [`judge`](Checker::judge) and never touches the
17//! rest. [`check`](Checker::check) wraps it and does three things the
18//! implementation is not trusted to do for itself:
19//!
20//! 1. **Refuses a checker that holds too much authority**, before it runs.
21//! 2. **Binds the verdict to what was checked**, by putting the digests of the
22//!    work order and the condensate into the evidence itself.
23//! 3. **Signs**, or fails.
24//!
25//! Every one of those is a property the system relies on across *all* checkers,
26//! and a guarantee spread across implementations is a guarantee that the sixth
27//! implementation breaks. This is the same reason `Slicer::condense` is a
28//! provided method in feature 07.
29//!
30//! # A checker cannot call a reasoner
31//!
32//! Not by convention, and not by code review. By its capability set.
33//!
34//! [`Checker::grants`] declares what the checker needs, and `check` refuses to
35//! run one whose declaration is not within [`checker_ceiling`]. The ceiling
36//! omits `net` and `secret`, which are the only two ways to reach a model
37//! provider: no egress, no API key, no inference. It also omits `time` and
38//! `rand`, which is the same mechanism enforcing the determinism requirement,
39//! and `fs:write`, so a checker cannot alter the thing it is judging.
40//!
41//! An undecidable comparison is a refusal, not a pass — [`Tri::Unknown`] is
42//! treated exactly as feature 02 treats it everywhere else.
43//!
44//! # A checker has no failure channel except its verdict
45//!
46//! [`judge`](Checker::judge) returns a [`Judgement`], not a `Result`. That is
47//! deliberate and it is the point of the three-valued [`Verdict`]: a checker
48//! whose compiler crashed, whose test run timed out, or whose input was not
49//! what it expected returns [`Verdict::Inconclusive`], which never commits.
50//!
51//! Had `judge` been fallible there would be two ways to say "no answer" — an
52//! `Err` and an `Inconclusive` — and somewhere a caller would handle the `Err`
53//! by logging it and carrying on. There is no such caller here because there is
54//! no such `Err`.
55//!
56//! [`CheckError`] exists for the two things that are *not* a checker's opinion:
57//! a checker that declared authority it may not hold, and a signature that
58//! could not be produced. Neither yields evidence, so neither commits.
59//!
60//! # Determinism
61//!
62//! A checker is a pure function of `(work order, condensate)`. No clock, no
63//! randomness, no iteration over a hashed collection, no network. Replay
64//! (US-1006) re-runs a recorded check and compares the evidence byte for byte,
65//! and ed25519 signatures are themselves deterministic, so the whole pipeline
66//! from inputs to signature reproduces — which is what makes a cassette test
67//! (US-1008) able to assert that a verdict did not change.
68//!
69//! [`Tri::Unknown`]: boundgraph_grant::Tri
70
71use std::fmt;
72
73use async_trait::async_trait;
74use boundgraph_core::{
75    Canonical, CheckerId, Condensate, Digest, Evidence, EvidenceKind, GrantSet, Verdict, WorkOrder,
76};
77use boundgraph_grant::{Tri, set_leq};
78
79use crate::sign::{Sign, SignError, sign_evidence};
80
81/// The widest authority any checker may hold.
82///
83/// | Included | Why |
84/// |---|---|
85/// | `fs:read:**`, `fs:list:**`, `fs:stat:**` | A checker has to see what it is judging. |
86/// | `proc:exec:**` | The useful checkers shell out — a compiler, a type checker, a test runner. |
87///
88/// What is left out matters more than what is in:
89///
90/// - **No `net`.** A checker cannot reach an inference API, so it cannot ask a
91///   model what it thinks. This is the mechanism behind "model-free", and it is
92///   why the claim survives a checker author who did not read the docs.
93/// - **No `secret`.** No API key, so no credential to reach one with even if
94///   egress were somehow available.
95/// - **No `time`, no `rand`.** The two ordinary sources of a verdict that
96///   differs between two runs over identical inputs.
97/// - **No `fs:write`.** A checker that could edit the workspace could make its
98///   own verdict true.
99///
100/// `proc:exec` is the one wide grant, and it is wide honestly: a subprocess can
101/// do whatever the sandbox lets it. Confining the child is feature 09's job,
102/// which is exactly the division `ARCHITECTURE.md` §3 draws — this crate
103/// decides *what authority is legitimate*, the sandbox decides *what is
104/// actually possible*.
105pub fn checker_ceiling() -> GrantSet {
106    GrantSet::parse(["fs:read:**", "fs:list:**", "fs:stat:**", "proc:exec:**"])
107        .expect("the checker ceiling is a compile-time constant and must parse")
108}
109
110/// The engine refused to produce evidence.
111///
112/// Note what is *not* here: a checker's own inability to decide. That is
113/// [`Verdict::Inconclusive`], which is an answer. These are failures to reach
114/// the point of having an answer at all.
115#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
116#[non_exhaustive]
117pub enum CheckError {
118    /// The checker declared authority a checker may not hold.
119    ///
120    /// Refused before [`Checker::judge`] runs, so the offending checker never
121    /// executes with it.
122    #[error("checker {checker} declares `{atom}`, which is outside what a checker may hold")]
123    Overreach {
124        /// Which checker.
125        checker: CheckerId,
126        /// The first atom that escaped the ceiling.
127        atom: String,
128    },
129
130    /// The checker's declared authority could not be proved to be within the
131    /// ceiling.
132    ///
133    /// A refusal rather than a pass. Goal G5: what cannot be proved is not
134    /// assumed, and a permission granted on a guess is the one failure this
135    /// whole layer exists to prevent.
136    #[error("cannot prove checker {checker}'s grants are within what a checker may hold")]
137    UndecidableGrants {
138        /// Which checker.
139        checker: CheckerId,
140    },
141
142    /// The work order or condensate could not be canonically encoded, so the
143    /// evidence cannot name what it covers.
144    #[error("checker {checker} could not address its inputs: {detail}")]
145    Address {
146        /// Which checker.
147        checker: CheckerId,
148        /// Why.
149        detail: String,
150    },
151
152    /// The verdict could not be signed, so there is no evidence and nothing
153    /// commits.
154    #[error("the verdict from {checker} could not be signed: {source}")]
155    Sign {
156        /// Which checker.
157        checker: CheckerId,
158        /// What went wrong.
159        #[source]
160        source: SignError,
161    },
162}
163
164/// What a checker decided, before it becomes signed [`Evidence`].
165///
166/// Carries the verdict and, optionally, further digests the checker looked at.
167/// It does *not* carry the work order or the condensate digests — those are
168/// added by [`Checker::check`], so that a checker cannot produce a verdict that
169/// fails to name what it judged.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct Judgement {
172    /// The outcome.
173    pub verdict: Verdict,
174    /// Anything else this verdict speaks about: a schema, a raw patch fetched
175    /// from the content store, a tool manifest.
176    pub also: Vec<Digest>,
177}
178
179impl Judgement {
180    /// The check succeeded.
181    pub fn pass() -> Self {
182        Self {
183            verdict: Verdict::Pass,
184            also: Vec::new(),
185        }
186    }
187
188    /// The check failed, with a reason a repair loop can act on.
189    ///
190    /// The reason is read by a model on the next attempt, so it should say what
191    /// was wrong and where, not merely that something was.
192    pub fn fail(reason: impl Into<String>) -> Self {
193        Self {
194            verdict: Verdict::Fail(reason.into()),
195            also: Vec::new(),
196        }
197    }
198
199    /// The check could not reach a conclusion.
200    ///
201    /// A timeout, a tool that crashed, an input this checker does not handle.
202    /// Never commits — see [`Verdict`].
203    pub fn inconclusive(reason: impl Into<String>) -> Self {
204        Self {
205            verdict: Verdict::Inconclusive(reason.into()),
206            also: Vec::new(),
207        }
208    }
209
210    /// Record another digest this verdict speaks about.
211    #[must_use]
212    pub fn covering(mut self, digest: Digest) -> Self {
213        self.also.push(digest);
214        self
215    }
216
217    /// Whether this judgement would permit a commit.
218    pub fn is_pass(&self) -> bool {
219        self.verdict.is_pass()
220    }
221}
222
223/// Whether a grant set is narrow enough for a checker to hold.
224///
225/// Exposed separately from [`Checker::check`] so the compiler can ask the same
226/// question statically when it resolves a `CheckerRef` (US-0401), rather than
227/// discovering at run time that a flow's checker was never legitimate.
228///
229/// # Errors
230///
231/// [`CheckError::Overreach`] if an atom escapes [`checker_ceiling`], or
232/// [`CheckError::UndecidableGrants`] if containment could not be decided.
233pub fn ensure_model_free(checker: &CheckerId, grants: &GrantSet) -> Result<(), CheckError> {
234    let ceiling = checker_ceiling();
235
236    match set_leq(grants, &ceiling) {
237        Tri::Yes => Ok(()),
238        Tri::No => {
239            // Name the first offender rather than the whole set: a message that
240            // dumps every grant makes the reader find the problem themselves.
241            let atom = grants
242                .iter()
243                .find(|a| !set_leq(&GrantSet::new([(*a).clone()]), &ceiling).is_yes())
244                .map(ToString::to_string)
245                .unwrap_or_else(|| "?".to_string());
246            Err(CheckError::Overreach {
247                checker: checker.clone(),
248                atom,
249            })
250        }
251        Tri::Unknown => Err(CheckError::UndecidableGrants {
252            checker: checker.clone(),
253        }),
254    }
255}
256
257/// Layer 5: turns a proposal and a result into a signed verdict.
258///
259/// Implement [`id`](Self::id) and [`judge`](Self::judge), and declare
260/// [`grants`](Self::grants) if the checker needs any. Do not implement
261/// [`check`](Self::check) — it is what enforces the contract, and the design of
262/// this module is that an implementation cannot opt out of it.
263#[async_trait]
264pub trait Checker: Send + Sync + fmt::Debug {
265    /// Which checker this is, and at what version.
266    ///
267    /// Recorded into every piece of evidence. The version is not decoration: a
268    /// verdict is only meaningful against a specific checker, and "the tests
269    /// passed" means nothing without knowing which runner said so.
270    fn id(&self) -> CheckerId;
271
272    /// The authority this checker needs.
273    ///
274    /// Nothing, by default. Checked against [`checker_ceiling`] before the
275    /// checker runs, so a declaration wide enough to reach a model is refused
276    /// rather than trusted.
277    fn grants(&self) -> GrantSet {
278        GrantSet::EMPTY
279    }
280
281    /// Decide. The part an implementation writes.
282    ///
283    /// Must be a pure function of its two arguments — see the module
284    /// documentation on determinism. There is no error return: a checker that
285    /// could not decide returns [`Judgement::inconclusive`], which never
286    /// commits.
287    async fn judge(&self, order: &WorkOrder, condensate: &Condensate) -> Judgement;
288
289    /// Check the authority, decide, bind the verdict to its inputs, and sign.
290    ///
291    /// In that order, and the order matters. The ceiling is enforced *before*
292    /// `judge` runs, so an over-authorised checker never executes; the inputs
293    /// are bound by this method rather than by the implementation, so a
294    /// checker cannot produce a verdict that omits what it judged.
295    ///
296    /// The evidence is always [`EvidenceKind::Deterministic`]. That is not a
297    /// parameter because this trait *is* the model-free checker: quorum
298    /// evidence comes from the `quorum` combinator (US-1403) and human evidence
299    /// from a gate, and neither of those is a `Checker`.
300    ///
301    /// # Errors
302    ///
303    /// [`CheckError::Overreach`] or [`CheckError::UndecidableGrants`] if the
304    /// checker may not hold what it declared, [`CheckError::Address`] if the
305    /// inputs could not be digested, or [`CheckError::Sign`] if the verdict
306    /// could not be signed. In every case there is no evidence, and so no
307    /// commit.
308    async fn check(
309        &self,
310        order: &WorkOrder,
311        condensate: &Condensate,
312        signer: &dyn Sign,
313    ) -> Result<Evidence, CheckError> {
314        let id = self.id();
315
316        // Before anything runs: a checker that could reach a reasoner does not
317        // get to produce a verdict, even a correct one.
318        ensure_model_free(&id, &self.grants())?;
319
320        let address = |value: Result<Digest, boundgraph_core::CanonicalError>| {
321            value.map_err(|e| CheckError::Address {
322                checker: id.clone(),
323                detail: e.to_string(),
324            })
325        };
326        let order_digest = address(order.digest())?;
327        let condensate_digest = address(condensate.digest())?;
328
329        let judgement = self.judge(order, condensate).await;
330
331        // The trait names the inputs, not the implementation. A checker that
332        // forgot to would produce evidence that could be reattached to a
333        // different patch, which is the forgery `Evidence::covers` exists to
334        // stop.
335        let inputs = [order_digest, condensate_digest]
336            .into_iter()
337            .chain(judgement.also);
338
339        sign_evidence(
340            judgement.verdict,
341            id.clone(),
342            inputs,
343            EvidenceKind::Deterministic,
344            signer,
345        )
346        .map_err(|source| CheckError::Sign {
347            checker: id,
348            source,
349        })
350    }
351}
352
353/// A checker that always returns the same verdict.
354///
355/// For tests, for cassette replay (US-1008), and for a flow being wired up
356/// before its real checker exists. It declares no grants and reads neither of
357/// its arguments, so it is trivially model-free and trivially deterministic.
358///
359/// Named for what it is. A checker that always passes is exactly the thing Law
360/// III exists to prevent, so it should be obvious in a flow definition that
361/// this is what is there.
362///
363/// # Example
364///
365/// ```
366/// use boundgraph_check::{Checker, Constant, LocalKey};
367/// # use boundgraph_core::{Condensate, CondensatePayload, Digest, GrantSet, Intent,
368/// #     ProposalOrigin, SchemaId, SlicerId, WorkOrder};
369/// # let order = WorkOrder::new(
370/// #     Intent::new("patch.apply", &"fix it")?,
371/// #     vec![],
372/// #     GrantSet::EMPTY,
373/// #     ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
374/// # );
375/// # let condensate = Condensate::new(
376/// #     SchemaId::new("boundgraph.log.v1", 1),
377/// #     CondensatePayload::inline(&"3 errors")?,
378/// #     Digest::of(b"raw"),
379/// #     400,
380/// #     true,
381/// #     SlicerId::new("log", 1),
382/// # );
383/// let key = LocalKey::from_seed("host-key-1", [7u8; 32]);
384///
385/// let evidence = tokio::runtime::Builder::new_current_thread()
386///     .build()?
387///     .block_on(Constant::failing("not implemented yet").check(&order, &condensate, &key))?;
388///
389/// assert!(!evidence.permits_commit());
390/// # Ok::<(), Box<dyn std::error::Error>>(())
391/// ```
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct Constant {
394    id: CheckerId,
395    verdict: Verdict,
396}
397
398impl Constant {
399    /// A checker that always passes.
400    ///
401    /// Use it knowingly. Nothing it vouches for has been checked.
402    pub fn passing() -> Self {
403        Self {
404            id: CheckerId::new("constant-pass", 1),
405            verdict: Verdict::Pass,
406        }
407    }
408
409    /// A checker that always fails, with a reason.
410    pub fn failing(reason: impl Into<String>) -> Self {
411        Self {
412            id: CheckerId::new("constant-fail", 1),
413            verdict: Verdict::Fail(reason.into()),
414        }
415    }
416
417    /// A checker that never decides.
418    pub fn undecided(reason: impl Into<String>) -> Self {
419        Self {
420            id: CheckerId::new("constant-inconclusive", 1),
421            verdict: Verdict::Inconclusive(reason.into()),
422        }
423    }
424
425    /// Name it something else, so a ledger says which stand-in this was.
426    #[must_use]
427    pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
428        self.id = CheckerId::new(name, version);
429        self
430    }
431}
432
433#[async_trait]
434impl Checker for Constant {
435    fn id(&self) -> CheckerId {
436        self.id.clone()
437    }
438
439    async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
440        Judgement {
441            verdict: self.verdict.clone(),
442            also: Vec::new(),
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::sign::{LocalKey, verify};
451    use boundgraph_core::{
452        CondensatePayload, GrantAtom, Intent, ProposalOrigin, SchemaId, SigningKeyId, SlicerId,
453    };
454
455    fn order() -> WorkOrder {
456        WorkOrder::new(
457            Intent::new("patch.apply", &"fix the off-by-one").unwrap(),
458            vec![],
459            GrantSet::parse(["fs:write:/src/**"]).unwrap(),
460            ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
461        )
462    }
463
464    fn condensate() -> Condensate {
465        Condensate::new(
466            SchemaId::new("boundgraph.diff.v1", 1),
467            CondensatePayload::inline(&"one file changed").unwrap(),
468            Digest::of(b"the raw patch"),
469            400,
470            false,
471            SlicerId::new("diff", 1),
472        )
473    }
474
475    fn key() -> LocalKey {
476        LocalKey::from_seed("host-key-1", [7u8; 32])
477    }
478
479    /// A checker declaring whatever a test needs it to.
480    #[derive(Debug)]
481    struct Declaring {
482        grants: GrantSet,
483    }
484
485    impl Declaring {
486        fn new(atoms: &[&str]) -> Self {
487            Self {
488                grants: GrantSet::parse(atoms.iter().copied()).expect("test grants should parse"),
489            }
490        }
491    }
492
493    #[async_trait]
494    impl Checker for Declaring {
495        fn id(&self) -> CheckerId {
496            CheckerId::new("declaring", 1)
497        }
498        fn grants(&self) -> GrantSet {
499            self.grants.clone()
500        }
501        async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
502            Judgement::pass()
503        }
504    }
505
506    // ── the contract ────────────────────────────────────────────────────────
507
508    /// The criterion: `check` takes a work order and a condensate.
509    #[tokio::test]
510    async fn check_takes_a_work_order_and_a_condensate_and_returns_evidence() {
511        let evidence = Constant::passing()
512            .check(&order(), &condensate(), &key())
513            .await
514            .unwrap();
515
516        assert_eq!(evidence.verdict, Verdict::Pass);
517        assert!(evidence.permits_commit());
518    }
519
520    /// The criterion: every checker declares an id and version, and it is
521    /// recorded in the evidence.
522    #[tokio::test]
523    async fn the_checker_id_and_version_reach_the_evidence() {
524        let evidence = Constant::passing()
525            .named("json-schema", 3)
526            .check(&order(), &condensate(), &key())
527            .await
528            .unwrap();
529
530        assert_eq!(evidence.checker, CheckerId::new("json-schema", 3));
531        assert_eq!(evidence.checker.version, 3);
532    }
533
534    /// The criterion: all three verdicts are reachable, and only one commits.
535    #[tokio::test]
536    async fn a_checker_returns_pass_fail_or_inconclusive() {
537        let cases = [
538            (Constant::passing(), true),
539            (Constant::failing("schema violation at /items/2"), false),
540            (Constant::undecided("the compiler timed out"), false),
541        ];
542
543        for (checker, commits) in cases {
544            let evidence = checker
545                .check(&order(), &condensate(), &key())
546                .await
547                .unwrap();
548            assert_eq!(evidence.permits_commit(), commits, "{checker:?}");
549        }
550    }
551
552    /// The criterion, stated on its own because it is the one a two-valued
553    /// verdict would have got wrong.
554    #[tokio::test]
555    async fn inconclusive_never_commits() {
556        let evidence = Constant::undecided("the test run timed out")
557            .check(&order(), &condensate(), &key())
558            .await
559            .unwrap();
560
561        assert!(!evidence.permits_commit());
562        assert_eq!(evidence.verdict.reason(), Some("the test run timed out"));
563    }
564
565    /// The trait binds the verdict to what was judged, so an implementation
566    /// cannot produce evidence that could be reattached elsewhere.
567    #[tokio::test]
568    async fn evidence_names_the_work_order_and_the_condensate() {
569        let (order, condensate) = (order(), condensate());
570        let evidence = Constant::passing()
571            .check(&order, &condensate, &key())
572            .await
573            .unwrap();
574
575        assert!(evidence.covers(&order.digest().unwrap()));
576        assert!(evidence.covers(&condensate.digest().unwrap()));
577        assert!(!evidence.covers(&Digest::of(b"some other artefact")));
578    }
579
580    #[tokio::test]
581    async fn a_checker_can_name_further_inputs_it_looked_at() {
582        #[derive(Debug)]
583        struct Schema;
584
585        #[async_trait]
586        impl Checker for Schema {
587            fn id(&self) -> CheckerId {
588                CheckerId::new("json-schema", 1)
589            }
590            async fn judge(&self, _o: &WorkOrder, _k: &Condensate) -> Judgement {
591                Judgement::pass().covering(Digest::of(b"the schema"))
592            }
593        }
594
595        let evidence = Schema.check(&order(), &condensate(), &key()).await.unwrap();
596        assert!(evidence.covers(&Digest::of(b"the schema")));
597    }
598
599    #[tokio::test]
600    async fn the_evidence_is_signed_and_verifies() {
601        let key = key();
602        let evidence = Constant::passing()
603            .check(&order(), &condensate(), &key)
604            .await
605            .unwrap();
606
607        assert!(verify(&evidence, &key.public_key()).is_ok());
608        assert_eq!(evidence.key, SigningKeyId("host-key-1".into()));
609        assert_eq!(evidence.kind, EvidenceKind::Deterministic);
610    }
611
612    /// The criterion: same inputs, same verdict — and, because signing is
613    /// deterministic too, the same bytes all the way out.
614    #[tokio::test]
615    async fn the_same_inputs_always_yield_the_same_evidence() {
616        let (order, condensate, key) = (order(), condensate(), key());
617        let checker = Constant::passing();
618
619        let first = checker.check(&order, &condensate, &key).await.unwrap();
620        for _ in 0..8 {
621            assert_eq!(
622                checker.check(&order, &condensate, &key).await.unwrap(),
623                first
624            );
625        }
626    }
627
628    #[tokio::test]
629    async fn different_inputs_yield_different_evidence() {
630        let key = key();
631        let checker = Constant::passing();
632
633        let one = checker.check(&order(), &condensate(), &key).await.unwrap();
634
635        let mut other = condensate();
636        other.raw = Digest::of(b"a different raw patch");
637        let two = checker.check(&order(), &other, &key).await.unwrap();
638
639        assert_ne!(one, two);
640    }
641
642    // ── the capability set ──────────────────────────────────────────────────
643
644    /// The criterion: a checker cannot call a reasoner, and it is the
645    /// capability set that says so.
646    #[tokio::test]
647    async fn a_checker_that_could_reach_a_model_provider_is_refused() {
648        for reaching in [
649            "net:egress:api.anthropic.com:443",
650            "net:egress:**",
651            "secret:use:anthropic_key",
652        ] {
653            let err = Declaring::new(&[reaching])
654                .check(&order(), &condensate(), &key())
655                .await
656                .unwrap_err();
657
658            assert!(
659                matches!(err, CheckError::Overreach { .. }),
660                "{reaching} should be refused, got {err:?}"
661            );
662        }
663    }
664
665    /// And it is refused *before* running, so an over-authorised checker never
666    /// executes at all.
667    #[tokio::test]
668    async fn an_over_authorised_checker_never_runs() {
669        #[derive(Debug)]
670        struct Explodes;
671
672        #[async_trait]
673        impl Checker for Explodes {
674            fn id(&self) -> CheckerId {
675                CheckerId::new("explodes", 1)
676            }
677            fn grants(&self) -> GrantSet {
678                GrantSet::parse(["net:egress:**"]).unwrap()
679            }
680            async fn judge(&self, _o: &WorkOrder, _k: &Condensate) -> Judgement {
681                panic!("an over-authorised checker must not reach its own body");
682            }
683        }
684
685        assert!(
686            Explodes
687                .check(&order(), &condensate(), &key())
688                .await
689                .is_err()
690        );
691    }
692
693    /// The same ceiling is what keeps a checker deterministic.
694    #[tokio::test]
695    async fn a_checker_cannot_hold_a_clock_or_a_source_of_randomness() {
696        for nondeterministic in ["time:now", "rand:bytes"] {
697            let err = Declaring::new(&[nondeterministic])
698                .check(&order(), &condensate(), &key())
699                .await
700                .unwrap_err();
701            assert!(
702                matches!(err, CheckError::Overreach { .. }),
703                "{nondeterministic}"
704            );
705        }
706    }
707
708    /// A checker that could edit the workspace could make its verdict true.
709    #[tokio::test]
710    async fn a_checker_cannot_write_to_what_it_is_judging() {
711        let err = Declaring::new(&["fs:write:/src/**"])
712            .check(&order(), &condensate(), &key())
713            .await
714            .unwrap_err();
715
716        let CheckError::Overreach { atom, .. } = err else {
717            panic!("expected Overreach");
718        };
719        assert!(
720            atom.contains("fs:write"),
721            "the message should name it: {atom}"
722        );
723    }
724
725    #[tokio::test]
726    async fn the_grants_a_real_checker_needs_are_allowed() {
727        let checker = Declaring::new(&["fs:read:/workspace/**", "proc:exec:/usr/bin/cargo"]);
728        assert!(checker.check(&order(), &condensate(), &key()).await.is_ok());
729    }
730
731    #[tokio::test]
732    async fn declaring_nothing_is_the_default_and_is_allowed() {
733        assert_eq!(Constant::passing().grants(), GrantSet::EMPTY);
734        assert!(
735            Constant::passing()
736                .check(&order(), &condensate(), &key())
737                .await
738                .is_ok()
739        );
740    }
741
742    /// Goal G5: an undecidable containment is a refusal, never a pass.
743    #[test]
744    fn an_undecidable_grant_is_refused_rather_than_assumed() {
745        let id = CheckerId::new("templated", 1);
746        // A template segment is symbolic, so containment cannot be decided.
747        let grants = GrantSet::new([GrantAtom::new("fs", "read", "{workspace}")]);
748
749        assert!(matches!(
750            ensure_model_free(&id, &grants),
751            Err(CheckError::UndecidableGrants { .. })
752        ));
753    }
754
755    #[test]
756    fn the_ceiling_omits_every_domain_that_could_reach_a_model() {
757        let ceiling = checker_ceiling();
758        for domain in ["net", "secret", "time", "rand", "env"] {
759            assert!(
760                ceiling.iter().all(|a| a.domain != domain),
761                "`{domain}` must not be within a checker's authority"
762            );
763        }
764        assert!(ceiling.iter().all(|a| a.action != "write"));
765    }
766
767    // ── signing failure ─────────────────────────────────────────────────────
768
769    /// The criterion from US-0802, restated at this boundary: no signature, no
770    /// evidence, no commit.
771    #[tokio::test]
772    async fn a_signing_failure_yields_no_evidence() {
773        #[derive(Debug)]
774        struct Broken;
775
776        impl Sign for Broken {
777            fn key_id(&self) -> SigningKeyId {
778                SigningKeyId("kms-key-1".into())
779            }
780            fn sign(&self, _payload: &[u8]) -> Result<boundgraph_core::Signature, SignError> {
781                Err(SignError::Unavailable {
782                    key: self.key_id(),
783                    detail: "the KMS did not answer".into(),
784                })
785            }
786        }
787
788        let err = Constant::passing()
789            .check(&order(), &condensate(), &Broken)
790            .await
791            .unwrap_err();
792
793        assert!(matches!(err, CheckError::Sign { .. }));
794    }
795
796    // ── judgements ──────────────────────────────────────────────────────────
797
798    #[test]
799    fn a_judgement_says_which_of_the_three_it_is() {
800        assert!(Judgement::pass().is_pass());
801        assert!(!Judgement::fail("no").is_pass());
802        assert!(!Judgement::inconclusive("could not tell").is_pass());
803    }
804
805    #[test]
806    fn a_failure_reason_survives_to_the_repair_loop() {
807        let judgement = Judgement::fail("expected string at /items/2, found number");
808        assert_eq!(
809            judgement.verdict.reason(),
810            Some("expected string at /items/2, found number")
811        );
812    }
813}