Skip to main content

safe_chains/engine/
level.rs

1//! Safety levels as predicates over profiles (v1.4 §4.1).
2//!
3//! A [`Level`] is a disjunction of allow [`Clause`]s plus an allow-only set of deny
4//! clauses (the `yolo` subtractive primitive, §4.1). A [`Clause`] is a conjunction of
5//! per-facet bounds — an ordinal ceiling/floor ([`OrdBound`]) or a categorical set —
6//! and an omitted facet is unconstrained. A capability is admissible iff **some**
7//! allow clause admits every one of its facets and **no** deny clause matches it; a
8//! profile passes iff every capability is admissible.
9//!
10//! Nothing here parses TOML or ships a default level yet — this is the algebra and
11//! its contract. Level authoring (TOML → `Level`) and the default set arrive next.
12
13use super::facet::*;
14
15/// An ordinal bound: `min ≤ term ≤ max`, either side optional. `at_most` is a
16/// ceiling (the common risk-facet form); `at_least` a floor (trust facets like
17/// pinning). An omitted side is unconstrained.
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub struct OrdBound<T> {
20    pub min: Option<T>,
21    pub max: Option<T>,
22}
23
24impl<T: Ord + Copy> OrdBound<T> {
25    /// `term ≤ ceiling`.
26    pub fn at_most(ceiling: T) -> Self {
27        Self { min: None, max: Some(ceiling) }
28    }
29    /// `term ≥ floor`.
30    pub fn at_least(floor: T) -> Self {
31        Self { min: Some(floor), max: None }
32    }
33    /// `term == exact`.
34    pub fn exactly(exact: T) -> Self {
35        Self { min: Some(exact), max: Some(exact) }
36    }
37    /// Whether `term` falls within the bound.
38    pub fn admits(self, term: T) -> bool {
39        self.min.is_none_or(|lo| lo <= term) && self.max.is_none_or(|hi| term <= hi)
40    }
41}
42
43fn ord_admits<T: Ord + Copy>(bound: Option<OrdBound<T>>, term: T) -> bool {
44    bound.is_none_or(|b| b.admits(term))
45}
46
47fn set_admits<T: Eq + Copy>(set: Option<&[T]>, term: T) -> bool {
48    set.is_none_or(|s| s.contains(&term))
49}
50
51/// The single facet constraint a capability failed, named in the taxonomy's own vocabulary.
52///
53/// Exists because the engine was write-only: `admits` answered yes/no and nothing reported WHICH
54/// axis said no, so both a user asking "why was this denied" and an author debugging a resolver had
55/// to bisect by editing facets and re-running. That is not a hypothetical cost — it is how an
56/// incomplete loopback delta got mistaken for a flawed approach.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FacetMismatch {
59    /// Dotted facet path as the taxonomy spells it (`network.payload`, `locus.remote`).
60    pub facet: &'static str,
61    /// The capability's term (`sends-host-data`).
62    pub actual: &'static str,
63    /// The constraint it failed (`<= fetches`, `one of [pinned, na]`).
64    pub bound: String,
65}
66
67impl std::fmt::Display for FacetMismatch {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "{} = {} (allowed: {})", self.facet, self.actual, self.bound)
70    }
71}
72
73fn ord_mismatch<T: Ord + Copy + FacetTerm>(
74    facet: &'static str,
75    bound: Option<OrdBound<T>>,
76    term: T,
77) -> Option<FacetMismatch> {
78    let b = bound?;
79    if b.admits(term) {
80        return None;
81    }
82    let bound = match (b.min, b.max) {
83        (None, Some(hi)) => format!("<= {}", hi.as_str()),
84        (Some(lo), None) => format!(">= {}", lo.as_str()),
85        (Some(lo), Some(hi)) => format!("{}..={}", lo.as_str(), hi.as_str()),
86        (None, None) => "any".to_string(),
87    };
88    Some(FacetMismatch { facet, actual: term.as_str(), bound })
89}
90
91fn set_mismatch<T: PartialEq + Copy + FacetTerm>(
92    facet: &'static str,
93    set: Option<&[T]>,
94    term: T,
95) -> Option<FacetMismatch> {
96    let s = set?;
97    if s.contains(&term) {
98        return None;
99    }
100    let bound = format!(
101        "one of [{}]",
102        s.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", "),
103    );
104    Some(FacetMismatch { facet, actual: term.as_str(), bound })
105}
106
107/// A conjunction of per-facet constraints. A default (all-`None`) clause admits every
108/// capability. Each ordinal facet takes an [`OrdBound`]; each categorical facet an
109/// allowed set. Fields are flattened per axis — a compound facet is never a single
110/// constraint (the R25 discipline).
111#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct Clause {
113    pub operation: Option<Vec<Operation>>,
114    pub local_locus: Option<OrdBound<LocalLocus>>,
115    pub remote_reach: Option<OrdBound<RemoteReach>>,
116    pub remote_binding: Option<Vec<RemoteBinding>>,
117    pub provenance: Option<OrdBound<Provenance>>,
118    pub scale: Option<OrdBound<Scale>>,
119    pub retrieval: Option<OrdBound<RetrievalGranularity>>,
120    pub authority: Option<OrdBound<Authority>>,
121    pub isolation: Option<OrdBound<Isolation>>,
122    pub reversibility: Option<OrdBound<Reversibility>>,
123    pub persistence_level: Option<OrdBound<PersistenceLevel>>,
124    pub trigger_escape: Option<OrdBound<TriggerEscape>>,
125    pub trigger_kind: Option<Vec<TriggerKind>>,
126    pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
127    pub disclosure_channel: Option<Vec<Channel>>,
128    pub disclosure_principal: Option<Vec<Principal>>,
129    pub secret_level: Option<OrdBound<SecretLevel>>,
130    pub secret_channel: Option<Vec<Channel>>,
131    pub secret_principal: Option<Vec<Principal>>,
132    pub net_direction: Option<OrdBound<NetDirection>>,
133    pub net_destination: Option<OrdBound<NetDestination>>,
134    pub net_payload: Option<OrdBound<NetPayload>>,
135    pub execution_trust: Option<OrdBound<ExecutionTrust>>,
136    pub supply_source: Option<Vec<SupplySource>>,
137    pub pinning: Option<OrdBound<Pinning>>,
138    pub exec_surface: Option<Vec<ExecSurface>>,
139    pub cost: Option<OrdBound<Cost>>,
140}
141
142impl Clause {
143    /// Whether this clause **admits** `cap` (allow-clause semantics).
144    pub fn admits(&self, cap: &Capability) -> bool {
145        self.check(cap, Role::Allow)
146    }
147
148    /// Whether this clause **matches** `cap` for removal (deny-clause semantics). Differs
149    /// from `admits` only on the optional `supply_chain`: a deny constrained on a
150    /// supply-chain facet does **not** match a capability that has no supply chain (it is
151    /// not in the denied corner), whereas an allow treats the absence as vacuously
152    /// satisfied. Sharing one body with a naive vacuous-true rule would make a
153    /// supply-chain-only deny wrongly match every non-supply-chain capability.
154    fn matches_as_deny(&self, cap: &Capability) -> bool {
155        self.check(cap, Role::Deny)
156    }
157
158    fn check(&self, cap: &Capability, role: Role) -> bool {
159        self.first_mismatch(cap, role).is_none()
160    }
161
162    /// The first facet constraint `cap` fails, or `None` if this clause admits it.
163    ///
164    /// `check` is defined in terms of THIS rather than the other way round. A parallel
165    /// "explain" walk beside a separate `&&` chain would drift the moment a facet is added, and a
166    /// diagnostic that disagrees with the predicate is worse than none — it would send an author
167    /// chasing the wrong axis. `clause_diagnostic_agrees_with_admits` pins the equivalence.
168    ///
169    /// Order is the taxonomy's axis order, not severity: it reports A failing facet, not the most
170    /// important one, since a clause is a conjunction and every failure is disqualifying.
171    /// Test-only view of `first_mismatch`. Takes the ROLE: the allow/deny asymmetry on the supply
172    /// chain means an allow-only equivalence proptest cannot see a deny-side inversion (it did not
173    /// — a `?` that reported "no complaint" for a definitely-failing deny got through it).
174    #[cfg(test)]
175    pub(crate) fn first_mismatch_for_test(
176        &self,
177        cap: &Capability,
178        deny_role: bool,
179    ) -> Option<FacetMismatch> {
180        self.first_mismatch(cap, if deny_role { Role::Deny } else { Role::Allow })
181    }
182
183    #[cfg(test)]
184    pub(crate) fn matches_as_deny_for_test(&self, cap: &Capability) -> bool {
185        self.matches_as_deny(cap)
186    }
187
188    fn first_mismatch(&self, cap: &Capability, role: Role) -> Option<FacetMismatch> {
189        set_mismatch("operation", self.operation.as_deref(), cap.operation)
190            .or_else(|| ord_mismatch("locus.local", self.local_locus, cap.locus.local))
191            .or_else(|| ord_mismatch("locus.remote", self.remote_reach, cap.locus.remote))
192            .or_else(|| set_mismatch("locus.binding", self.remote_binding.as_deref(), cap.locus.binding))
193            .or_else(|| ord_mismatch("locus.provenance", self.provenance, cap.locus.provenance))
194            .or_else(|| ord_mismatch("scale", self.scale, cap.scale))
195            .or_else(|| ord_mismatch("retrieval", self.retrieval, cap.retrieval))
196            .or_else(|| ord_mismatch("authority", self.authority, cap.authority))
197            .or_else(|| ord_mismatch("isolation", self.isolation, cap.isolation))
198            .or_else(|| ord_mismatch("reversibility", self.reversibility, cap.reversibility))
199            .or_else(|| ord_mismatch("persistence.level", self.persistence_level, cap.persistence.level))
200            .or_else(|| ord_mismatch("persistence.trigger.escape", self.trigger_escape, cap.persistence.trigger.escape))
201            .or_else(|| set_mismatch("persistence.trigger.kind", self.trigger_kind.as_deref(), cap.persistence.trigger.kind))
202            .or_else(|| ord_mismatch("disclosure.audience", self.disclosure_audience, cap.disclosure.audience))
203            .or_else(|| set_mismatch("disclosure.channel", self.disclosure_channel.as_deref(), cap.disclosure.channel))
204            .or_else(|| set_mismatch("disclosure.principal", self.disclosure_principal.as_deref(), cap.disclosure.principal))
205            .or_else(|| ord_mismatch("secret.level", self.secret_level, cap.secret.level))
206            .or_else(|| set_mismatch("secret.channel", self.secret_channel.as_deref(), cap.secret.channel))
207            .or_else(|| set_mismatch("secret.principal", self.secret_principal.as_deref(), cap.secret.principal))
208            .or_else(|| ord_mismatch("network.direction", self.net_direction, cap.network.direction))
209            .or_else(|| ord_mismatch("network.destination", self.net_destination, cap.network.destination))
210            .or_else(|| ord_mismatch("network.payload", self.net_payload, cap.network.payload))
211            .or_else(|| ord_mismatch("execution.trust", self.execution_trust, cap.execution.trust))
212            .or_else(|| self.supply_chain_mismatch(cap.execution.supply_chain, role))
213            .or_else(|| ord_mismatch("cost", self.cost, cap.cost))
214    }
215
216    /// Supply-chain constraints apply only to network-sourced code. For an **allow**, a
217    /// capability with no supply chain (`None`) satisfies them vacuously. For a **deny**,
218    /// a clause that constrains the supply chain does **not** match a `None` capability —
219    /// it is not in the denied corner — while an unconstrained deny is unaffected.
220    fn supply_chain_mismatch(&self, sc: Option<SupplyChain>, role: Role) -> Option<FacetMismatch> {
221        if self.supply_chain_admits(sc, role) {
222            return None;
223        }
224        let Some(sc) = sc else {
225            // A DENY constrained on the supply chain does not match a capability that has none.
226            // `?` here would report "no complaint", which `check` reads as ADMITS — inverting the
227            // rule for exactly the case the asymmetry exists to handle.
228            return Some(FacetMismatch {
229                facet: "execution.supply_chain",
230                actual: "absent",
231                bound: "this clause constrains the supply chain, which this capability has none of"
232                    .to_string(),
233            });
234        };
235        set_mismatch("supply_chain.source", self.supply_source.as_deref(), sc.source)
236            .or_else(|| ord_mismatch("supply_chain.pinning", self.pinning, sc.pinning))
237            .or_else(|| set_mismatch("supply_chain.exec_surface", self.exec_surface.as_deref(), sc.exec_surface))
238    }
239
240    fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
241        match sc {
242            None => match role {
243                Role::Allow => true,
244                Role::Deny => {
245                    self.supply_source.is_none()
246                        && self.pinning.is_none()
247                        && self.exec_surface.is_none()
248                }
249            },
250            Some(sc) => {
251                set_admits(self.supply_source.as_deref(), sc.source)
252                    && ord_admits(self.pinning, sc.pinning)
253                    && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
254            }
255        }
256    }
257}
258
259/// Which side a clause is evaluated on — the two differ only on absent `supply_chain`.
260#[derive(Copy, Clone, PartialEq, Eq)]
261enum Role {
262    Allow,
263    Deny,
264}
265
266/// A safety level: a name, its allow clauses (disjunction), and its deny clauses
267/// (allow-only subtractive corners, §4.1).
268#[derive(Clone, Debug, Default, PartialEq, Eq)]
269pub struct Level {
270    pub name: String,
271    pub allow: Vec<Clause>,
272    pub deny: Vec<Clause>,
273}
274
275impl Level {
276    /// An empty level (admits only the empty profile). Build it up with
277    /// [`Level::allowing`] / [`Level::denying`].
278    pub fn new(name: impl Into<String>) -> Self {
279        Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
280    }
281
282    /// Add an allow clause (widens the admissible region).
283    #[must_use]
284    pub fn allowing(mut self, clause: Clause) -> Self {
285        self.allow.push(clause);
286        self
287    }
288
289    /// Add a deny clause (subtracts a corner; never grants).
290    #[must_use]
291    pub fn denying(mut self, clause: Clause) -> Self {
292        self.deny.push(clause);
293        self
294    }
295
296    /// Whether a single capability is admissible: some allow clause admits it and no
297    /// deny clause matches it.
298    /// Why this level does not admit `cap`, or `None` if it does.
299    ///
300    /// A level is a DISJUNCTION of allow clauses, so a rejected capability failed all of them and
301    /// there is a choice of which complaint to report. Clauses are usually split by operation
302    /// (`editor` allows observes under one clause and mutates under another), so the first clause's
303    /// gripe is frequently "operation = mutate, allowed: [observe]" — true, useless, and it hides
304    /// the facet the author actually needs to change. Preferring a clause whose OPERATION already
305    /// matches surfaces the real blocker.
306    pub fn nearest_miss(&self, cap: &Capability) -> Option<FacetMismatch> {
307        if self.allow.iter().any(|c| c.admits(cap)) {
308            // Admitted by an allow, so if the level still rejects it a deny clause removed it.
309            return self.deny.iter().find(|c| c.matches_as_deny(cap)).map(|_| FacetMismatch {
310                facet: "deny-clause",
311                actual: "matched",
312                bound: "removed by an explicit deny clause".to_string(),
313            });
314        }
315        if self.allow.is_empty() {
316            return Some(FacetMismatch {
317                facet: "level",
318                actual: "any capability",
319                bound: "nothing — this level declares no allow clause".to_string(),
320            });
321        }
322        let on_topic = |c: &&Clause| {
323            c.operation.as_ref().is_none_or(|ops| ops.contains(&cap.operation))
324        };
325        self.allow
326            .iter()
327            .filter(on_topic)
328            .chain(self.allow.iter())
329            .find_map(|c| c.first_mismatch(cap, Role::Allow))
330    }
331
332    pub fn admits_capability(&self, cap: &Capability) -> bool {
333        self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
334    }
335
336    /// Whether a whole profile passes: every capability is admissible. The empty
337    /// profile passes vacuously.
338    pub fn admits(&self, profile: &Profile) -> bool {
339        profile.capabilities.iter().all(|c| self.admits_capability(c))
340    }
341
342    /// Author a level by extending `base` (R27: `extends` only loosens). The result
343    /// inherits `base`'s allow *and* deny clauses unchanged and adds only allow
344    /// clauses — it cannot drop an allow or add a deny, so `extends ⇒ superset` holds
345    /// by construction. `yolo`'s subtractive `deny` is authored directly, never via
346    /// `extend`.
347    #[must_use]
348    pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
349        let mut allow = base.allow.clone();
350        allow.extend(extra_allow);
351        Level { name: name.into(), allow, deny: base.deny.clone() }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use proptest::prelude::*;
359
360    fn cap(op: Operation) -> Capability {
361        Capability::new(op)
362    }
363
364    #[test]
365    fn empty_clause_admits_everything_empty_allow_admits_nothing() {
366        let all = Level::new("all").allowing(Clause::default());
367        let nothing = Level::new("nothing");
368        let destroy = Profile::of(vec![cap(Operation::Destroy)]);
369        assert!(all.admits(&destroy));
370        assert!(!nothing.admits(&destroy));
371        // both admit the empty profile vacuously
372        assert!(all.admits(&Profile::default()));
373        assert!(nothing.admits(&Profile::default()));
374    }
375
376    #[test]
377    fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
378        let read_local = Level::new("read-local").allowing(Clause {
379            operation: Some(vec![Operation::Observe]),
380            local_locus: Some(OrdBound::at_most(LocalLocus::User)),
381            secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
382            net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
383            ..Default::default()
384        });
385
386        let plain_read = Profile::of(vec![{
387            let mut c = cap(Operation::Observe);
388            c.locus.local = LocalLocus::Worktree;
389            c
390        }]);
391        assert!(read_local.admits(&plain_read));
392
393        let secret_read = Profile::of(vec![{
394            let mut c = cap(Operation::Observe);
395            c.locus.local = LocalLocus::User;
396            c.secret.level = SecretLevel::Reads;
397            c
398        }]);
399        assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
400
401        let destroy = Profile::of(vec![cap(Operation::Destroy)]);
402        assert!(!read_local.admits(&destroy));
403    }
404
405    #[test]
406    fn yolo_deny_carves_out_the_catastrophe_corner() {
407        let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
408            operation: Some(vec![Operation::Destroy]),
409            reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
410            scale: Some(OrdBound::at_least(Scale::Unbounded)),
411            ..Default::default()
412        });
413
414        // rm -rf ./node_modules — destroy·bounded·recoverable → admitted
415        let bounded = Profile::of(vec![{
416            let mut c = cap(Operation::Destroy);
417            c.scale = Scale::Bounded;
418            c.reversibility = Reversibility::Recoverable;
419            c
420        }]);
421        assert!(yolo.admits(&bounded));
422
423        // rm -rf / — destroy·unbounded·irreversible → denied by the corner
424        let wipe = Profile::of(vec![{
425            let mut c = cap(Operation::Destroy);
426            c.scale = Scale::Unbounded;
427            c.reversibility = Reversibility::Irreversible;
428            c
429        }]);
430        assert!(!yolo.admits(&wipe));
431    }
432
433    #[test]
434    fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
435        // a developer-shaped clause: network-sourced execution is fine, but only from a
436        // recognized registry (not an unverified URL).
437        let dev = Level::new("dev").allowing(Clause {
438            execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
439            supply_source: Some(vec![
440                SupplySource::PublicRegistry,
441                SupplySource::SignedRepo,
442                SupplySource::PrivateRegistry,
443                SupplySource::Vendored,
444            ]),
445            ..Default::default()
446        });
447
448        // cargo build — network-sourced from a public registry
449        let build = Profile::of(vec![{
450            let mut c = cap(Operation::Execute);
451            c.execution = Execution {
452                trust: ExecutionTrust::NetworkSourced,
453                supply_chain: Some(SupplyChain {
454                    source: SupplySource::PublicRegistry,
455                    pinning: Pinning::HashVerified,
456                    exec_surface: ExecSurface::BuildScript,
457                }),
458            };
459            c
460        }]);
461        assert!(dev.admits(&build), "cargo build from a registry");
462
463        // curl | sh — network-sourced from an unverified URL
464        let curl_sh = Profile::of(vec![{
465            let mut c = cap(Operation::Execute);
466            c.execution = Execution {
467                trust: ExecutionTrust::NetworkSourced,
468                supply_chain: Some(SupplyChain {
469                    source: SupplySource::UnverifiedUrl,
470                    pinning: Pinning::Floating,
471                    exec_surface: ExecSurface::RunArtifact,
472                }),
473            };
474            c
475        }]);
476        assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
477
478        // cat file — no downloaded code, so the supply-chain constraint is vacuous
479        let plain = Profile::of(vec![cap(Operation::Observe)]);
480        assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
481    }
482
483    #[test]
484    fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
485        // "deny unverified-url sources" must NOT accidentally deny `cat file` (no supply
486        // chain) — the vacuous-truth asymmetry between allow and deny (review finding #1).
487        let level = Level::new("x").allowing(Clause::default()).denying(Clause {
488            supply_source: Some(vec![SupplySource::UnverifiedUrl]),
489            ..Default::default()
490        });
491
492        let plain = Profile::of(vec![cap(Operation::Observe)]);
493        assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
494
495        // but curl|sh (network-sourced, unverified-url) IS still caught by the deny corner
496        let curl_sh = Profile::of(vec![{
497            let mut c = cap(Operation::Execute);
498            c.execution = Execution {
499                trust: ExecutionTrust::NetworkSourced,
500                supply_chain: Some(SupplyChain {
501                    source: SupplySource::UnverifiedUrl,
502                    pinning: Pinning::Floating,
503                    exec_surface: ExecSurface::RunArtifact,
504                }),
505            };
506            c
507        }]);
508        assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
509    }
510
511    #[test]
512    fn extend_inherits_deny_and_adds_allow() {
513        let base = Level::new("base")
514            .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
515            .denying(Clause {
516                local_locus: Some(OrdBound::at_least(LocalLocus::Device)),
517                ..Default::default()
518            });
519        let child = Level::extend(
520            &base,
521            "child",
522            vec![Clause {
523                operation: Some(vec![Operation::Create, Operation::Mutate]),
524                ..Default::default()
525            }],
526        );
527
528        assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
529        assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
530
531        let device = Profile::of(vec![{
532            let mut c = cap(Operation::Mutate);
533            c.locus.local = LocalLocus::Device;
534            c
535        }]);
536        assert!(!child.admits(&device), "inherited deny still bites");
537    }
538
539    // ── the algebra contract ──────────────────────────────────────────────────────
540    //
541    // Generators live in `super::super::testgen` (shared with the authoring tests).
542    use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
543
544    proptest! {
545        /// Totality: every level yields a decision on every profile, deterministically,
546        /// never a panic (the worst-case rule guarantees a total function).
547        #[test]
548        fn totality(level in arb_level(), profile in arb_profile()) {
549            let first = level.admits(&profile);
550            prop_assert_eq!(first, level.admits(&profile));
551        }
552
553        /// extends ⇒ superset: a level built by `extend` admits everything its base
554        /// admits (R27, encoded structurally).
555        #[test]
556        fn extends_is_a_superset(
557            base in arb_level(),
558            extra in prop::collection::vec(arb_clause(), 0..3),
559            profile in arb_profile(),
560        ) {
561            let extended = Level::extend(&base, "child", extra);
562            prop_assert!(!base.admits(&profile) || extended.admits(&profile));
563        }
564
565        /// deny-monotonicity: adding a deny clause can only *shrink* the admitted set —
566        /// a deny never grants. This is what makes the subtractive primitive safe.
567        #[test]
568        fn deny_only_shrinks(
569            level in arb_level(),
570            extra_deny in arb_clause(),
571            profile in arb_profile(),
572        ) {
573            let stricter = level.clone().denying(extra_deny);
574            prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
575        }
576    }
577}