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}