Skip to main content

candor_classify/
gate.rs

1//! ⟨0.24⟩ THE GATE — SPEC §6.2 matching over an ALREADY-ACCUMULATED signature, and the ONLY copy of
2//! that matching in the stable toolchain.
3//!
4//! **THE SEAM.** [`GateInput`] is the boundary between *what produced the signature* and *what §6.2
5//! does with it*. Every field is already accumulated: this module runs no fixpoint, opens no file and
6//! consults no scan state, so the same matching code serves both routes in —
7//!
8//!   - `candor-scan … --policy <f>` builds a `GateInput` from the classifier's transitive accumulators
9//!     (`crate::gate::policy_violations`, which is now a thin wrapper);
10//!   - `candor-query gate --report <loc> --policy <f>` (SPEC §3.1 ⟨0.24⟩) builds one from a WRITTEN
11//!     report and nothing else.
12//!
13//! That split is the whole point of §3.1 ⟨0.24⟩: until it existed the gate was reachable only THROUGH
14//! the classifier, so a defect in the gate and a defect in the classifier were indistinguishable from
15//! any test that could be written. Do NOT re-implement the matching on the report side — the §6.2
16//! clause that mandates the verb was written about exactly that mistake.
17
18use crate::policy::{literal_allowed, reason_class_matches, scope_matches, scope_matches_permitted,
19                    ParsedPolicy, PolicyRule};
20use candor_report::GateViolation;
21use std::collections::{BTreeSet, HashMap};
22
23/// ⟨0.20⟩ The `Net` destination classes an fn reaches (transitive) — the SINGLE derivation shared by the
24/// report's `netClass` field (candor-scan's writer) and the gate: an exact host-literal match
25/// ([`crate::net_dest_class`]) for the visible hosts, plus the fail-closed `unknown-host` when the Net
26/// surface is masked (`incomplete` has Net) OR carries no visible host (a runtime endpoint). Call only
27/// for an fn known to have Net; returns sorted.
28///
29/// It lives beside the gate rather than in the scanner because the report field and the gate filter MUST
30/// be the same set: `gate --report` reads `netClass` off the wire and the scan derives it here, and the
31/// §3.1 ⟨0.24⟩ byte-equivalence obligation is exactly the claim that those two agree.
32pub fn net_classes_of<E: AsRef<str> + Ord>(
33    q: &str,
34    hostsacc: &HashMap<String, BTreeSet<String>>,
35    incompleteacc: &HashMap<String, BTreeSet<E>>,
36    partners: &BTreeSet<String>,
37) -> Vec<String> {
38    let mut classes: BTreeSet<String> = hostsacc
39        .get(q)
40        .into_iter()
41        .flatten()
42        .map(|h| crate::net_dest_class(h, partners).to_string())
43        .collect();
44    let masked = incompleteacc.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == "Net"));
45    let no_hosts = hostsacc.get(q).map(|s| s.is_empty()).unwrap_or(true);
46    if masked || no_hosts {
47        classes.insert("unknown-host".to_string());
48    }
49    classes.into_iter().collect()
50}
51
52/// ⟨0.24⟩ THE GATE'S INPUT — one signature per function, every field already TRANSITIVE.
53///
54/// `E` is the effect-name representation: `&'static str` on the scan route (the classifier's interned
55/// vocabulary) and `String` on the report route (the wire's names, taken VERBATIM — a report naming an
56/// effect this build's vocabulary does not list must still trip a `pure` rule, so the names are never
57/// filtered through a known-effect allowlist on the way in).
58impl<'a, E: AsRef<str> + Ord> GateInput<'a, E> {
59    /// The name to match a policy scope against, and to print. Identity when no map was supplied.
60    pub fn disp<'x>(&'x self, k: &'x str) -> &'x str {
61        self.display.get(k).map(|v| v.as_str()).unwrap_or(k)
62    }
63
64    /// ⟨0.32⟩ The §2.2 UNIT IDENTITY this key stands for — what a verdict row carries so a consumer can
65    /// tell two units apart (SPEC §2). EMPTY when the caller has none to give, and empty is then omitted
66    /// from the wire: *this producer cannot answer* beats a fabricated id.
67    ///
68    /// A separate map from [`Self::display`] rather than a second use of the key, because the two routes
69    /// disagree about what the key IS: the report route keys by `hash` already, the scan route keys by
70    /// the qualified NAME and must qualify it with the crate. Reading identity off the key would have
71    /// made the two routes emit different `hash` values for one unit, which §3.1 byte-equality forbids
72    /// and which no single-route test could see.
73    pub fn unit<'x>(&'x self, k: &'x str) -> &'x str {
74        self.hash.get(k).map(|v| v.as_str()).unwrap_or("")
75    }
76}
77
78pub struct GateInput<'a, E: AsRef<str> + Ord> {
79    /// Every UNIT the gate ranges over, in the caller's order — an opaque KEY, not necessarily a name.
80    pub all: &'a [String],
81    /// ⟨0.32⟩ key -> the name to MATCH and DISPLAY. Empty means the keys are already names.
82    ///
83    /// A multi-report gate must join by `hash` and never by bare `fn` (SPEC §2.2), because two members
84    /// of a workspace legitimately share a name — and merging them was measured turning a refusal into
85    /// `policy ✓` by letting one borrow the other's Unknown reason class. But a hash is `package#fn`,
86    /// and a POLICY SCOPE is written against the name (`deny Exec app::`), so keying by hash without
87    /// this map silently stops scopes matching: a false green introduced by fixing a false green.
88    /// Identity is the default, so the single-report callers are unaffected.
89    pub display: &'a std::collections::HashMap<String, String>,
90    /// ⟨0.32⟩ key -> the §2.2 UNIT IDENTITY (`package#fn`) a verdict row carries. Missing means this
91    /// caller has none, and the row then omits the field — see [`Self::unit`].
92    pub hash: &'a std::collections::HashMap<String, String>,
93    /// Per fn, the TRANSITIVE effect set — the model's `S`, with candor's `Unknown` marker carried as a
94    /// member (this engine's encoding of `D ≠ ∅`).
95    pub inferred: &'a HashMap<String, BTreeSet<E>>,
96    /// The call graph AS-EFF-009 walks.
97    pub calls: &'a HashMap<String, BTreeSet<String>>,
98    /// Per fn, the TRANSITIVE literal surface AS-EFF-008 certifies against.
99    pub hosts: &'a HashMap<String, BTreeSet<String>>,
100    pub cmds: &'a HashMap<String, BTreeSet<String>>,
101    pub paths: &'a HashMap<String, BTreeSet<String>>,
102    pub tables: &'a HashMap<String, BTreeSet<String>>,
103    /// Per fn, the effects whose literal surface is structurally INCOMPLETE — the AS-EFF-008 fail-closed
104    /// marker, without which a benign visible literal masks an invisible forbidden endpoint.
105    pub surface_incomplete: &'a HashMap<String, BTreeSet<E>>,
106    /// Per fn, the TRANSITIVE reason-class tokens — the model's `D` (§6.2 ⟨0.19⟩). The Unknown EFFECT
107    /// propagates along the call graph, so its REASON must too: else `deny E Unknown[reflect]` at a
108    /// caller inheriting Unknown from a reflect-caused callee sees no class and does NOT fire.
109    pub reason_classes: &'a HashMap<String, BTreeSet<String>>,
110    /// Per `Net`-bearing fn, its ⟨0.20⟩ destination classes, ALREADY derived — by [`net_classes_of`] on
111    /// the scan route, read verbatim from the report's `netClass` on the report route. Absent ⇒ empty.
112    pub net_classes: &'a HashMap<String, Vec<String>>,
113}
114
115/// ⟨0.24⟩ ONE `(rule, function)` THE GATE COULD NOT EVALUATE — SPEC §3.1: *"a rule FIRES on a function
116/// only where the match is evidenced by that function's own entry, and is WITHHELD exactly where it is
117/// not. Withholding is per `(rule, function)`, never whole-policy."*
118///
119/// A withheld pair is NOT a tolerated one. Tolerating means the evidence was read and did not match;
120/// withholding means there was no evidence to read, and the two must not arrive at a consumer wearing the
121/// same face. The caller decides the disposition — a violation elsewhere dominates (exit 1, disclose), a
122/// sole withholding is a refusal (exit 2) — but it can only do that if the fact reaches it, which is why
123/// this rides out of [`gate`] beside the violations instead of being logged here.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Withheld {
126    /// The rule's source line, verbatim (`PolicyRule::raw`).
127    pub rule: String,
128    /// The function the rule could not be evaluated ON. The same rule may fire on another.
129    pub func: String,
130    /// Which narrowing filter had nothing to read — `"Unknown"` or `"Net"`.
131    pub filter: &'static str,
132}
133
134/// ⟨0.24⟩ What [`gate`] returns: the violations it is SURE of, and the `(rule, function)` pairs it
135/// WITHHELD. Both halves travel, because the verdict is both (SPEC §3.1).
136#[derive(Debug, Default)]
137pub struct GateOutcome {
138    /// Sorted by (rule, detail).
139    pub violations: Vec<GateViolation>,
140    /// Sorted by (rule, func). Empty on every policy whose filters the signature can answer.
141    pub withheld: Vec<Withheld>,
142    /// ⟨0.27⟩ SPEC §4 — the RAW TEXT of every rule whose SCOPE bound no function, sorted. A rule that
143    /// bound nothing was evaluated and matched nothing, so it cannot have caught anything; scoring it as
144    /// satisfied makes a one-character typo in a layer name a permanently green gate. This is a
145    /// DISCLOSURE beside the verdict, never a new verdict: the caller prints it and MUST NOT let it
146    /// change the exit code (a zero-match rule is legitimate when one policy is shared across repos).
147    pub zero_match: Vec<String>,
148    /// SOUNDNESS R301 — every `deny`/`pure` rule whose scope names a BARE FUNCTION (no `::`) and bound
149    /// MORE THAN ONE, as `(raw rule, the names it bound)`. Sorted, and a DISCLOSURE beside the verdict
150    /// exactly like `zero_match`: the exit code must not move.
151    ///
152    /// **WHY.** §6.2 scope matching is a PREFIX match, which is the documented behaviour and is not in
153    /// question here. What is in question is that it is INVISIBLE: `deny Fs either` also binds
154    /// `either_ifelse` and `either_match`, and `deny Fs t_fish` binds `t_fish_both`. An UNBOUND rule
155    /// announces itself (`matched NO function`); an OVER-bound one announced nothing and looked exactly
156    /// like a rule that bound the one function its author named. Both matrix agents on 2026-09-07 were
157    /// caught by it, each noticing only because an unrelated control disagreed, and in every case the
158    /// scoped column read exit 1 for a function that is actually SILENT — a cardinal sin hidden behind
159    /// a neighbour's name.
160    ///
161    /// **SCOPED TO BARE NAMES ON PURPOSE.** A layer scope (`handlers::`) is MEANT to bind many, so
162    /// announcing that would be noise on every real policy and the disclosure would be ignored. A scope
163    /// with no `::` is someone naming a function, and binding two is a surprise worth one line.
164    pub multi_match: Vec<(String, Vec<String>)>,
165}
166
167/// ⟨0.24⟩ What one §6.2 `deny`/`pure` rule DOES to one function's signature — see [`rule_hits`].
168pub struct RuleHits<'a> {
169    /// The effects this rule CHARGES on this function, after both narrowing filters. Empty ⇒ the rule
170    /// does not fire here, which is what the disclosure calls PASSING.
171    pub hits: Vec<&'a str>,
172    /// The filters — `"Unknown"` / `"Net"` — that had no evidence to read, in that order. The hit was
173    /// dropped from `hits` AND the fact rides out, because dropping it silently is the mirror defect.
174    pub withheld: Vec<&'static str>,
175}
176
177/// ⟨0.24⟩ WHAT ONE §6.2 `deny`/`pure` RULE DOES TO ONE FUNCTION'S SIGNATURE — the firing decision,
178/// extracted so it has exactly one implementation.
179///
180/// **WHY IT IS A FUNCTION NOW.** It was inline in [`gate`], and the provable-purity disclosure
181/// ([`crate::policy::unverified_hole_rule`]) carried its own second copy that asked a coarser question:
182/// "does the rule NAME an effect this function has?", computed from `r.effects` alone. That copy could
183/// not see a narrowing filter, so it disagreed with the gate on exactly the rules the ⟨0.24⟩ rung added
184/// — and the disagreement ran the LOSING way. A hole is a function that PASSES its rule while `Unknown`,
185/// so a rule the gate TOLERATES (`deny Unknown[reflect]` over an `indirect` hole) was read by the
186/// disclosure as a violation-that-isn't and dropped from the output: `unverified` printed **"every
187/// function in a pure/deny layer is PROVABLY clean ✓"** over a function the gate had just declined to
188/// clear. MEASURED 2026-07-28 on a one-function crate; reachable with NO alias in play, one layer below
189/// the alias-widening defect `ea0df4f` closed.
190///
191/// The caller does the SCOPE test and owns the disposition of `withheld`; this answers only "given that
192/// this rule governs this function, what does it charge, and what could it not evaluate?".
193///
194/// `reason_classes` is the ACCUMULATED (post-fixpoint) class set — `None`/empty means the signature
195/// carries none, which is NOT determinable and is withheld, never floored. `net_classes` is the fn's
196/// ⟨0.20⟩ destination classes, likewise already derived.
197pub fn rule_hits<'a>(
198    r: &PolicyRule,
199    effects: &[&'a str],
200    reason_classes: Option<&BTreeSet<String>>,
201    net_classes: &[String],
202) -> RuleHits<'a> {
203    let mut withheld: Vec<&'static str> = Vec::new();
204    let mut hits: Vec<&str> = if r.effects.is_empty() {
205        // `pure` — every EFFECT, but NOT `Unknown`: the §4 trust marker is not an effect
206        // (AS-EFF-003's concern; `deny Unknown <scope>` is the explicit knob). The reference
207        // engine and the deep backend exclude it identically — this engine wrongly counted an
208        // Unknown-only fn as a `pure` violation until 2026-07-09 (a cross-engine verdict split
209        // on the same policy file).
210        effects.iter().copied().filter(|e| *e != "Unknown").collect()
211    } else {
212        effects.iter().copied().filter(|e| r.effects.contains(e)).collect()
213    };
214    // Reason-scoped Unknown: a `deny E Unknown[classes]` (non-empty filter) keeps its Unknown hit
215    // ONLY for a fn whose TRANSITIVE reason classes include one of those classes; else tolerate it
216    // (wrong reason-class). Concrete effects in `hits` are untouched — only Unknown is scoped.
217    if hits.contains(&"Unknown") && !r.unknown_classes.is_empty() {
218        let want: BTreeSet<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
219        // An Unknown with NO recorded reason is `unresolved` (conservative — stays in
220        // `[*]`/`[unresolved]`). THIS IS A NET, NOT A ROUTE. It is per FUNCTION and keys on the
221        // ABSENCE of a class set, so any other reason on the same function hides whatever it was
222        // covering — which is how a reasonless chained-dep `Unknown` went ungated on every consumer
223        // that also had a reason of its own. That case now CONTRIBUTES `unresolved` where the
224        // signature is BUILT (candor-scan's `reason_class_direct`; `gate_input_from_report` on the
225        // report route) instead of arriving here by absence. What is left for the absence arm is
226        // the RELEASE-mode gap: the writer's §4 invariant is a `debug_assert`, so a future path
227        // that puts `Unknown` into `direct` with no reason fails closed here rather than escaping
228        // the gate. Not dead — it is pinned by
229        // `reason_scoped_unknown_gate_fires_on_match_tolerates_mismatch`.
230        //
231        // ⟨0.24⟩ The rule itself lives in `crate::policy::reason_class_matches` because
232        // `unverified --class` must select over exactly the set this gate scopes over: a gate and
233        // the disclosure naming the holes that gate did not prove, disagreeing, is the defect.
234        //
235        // ⟨0.24⟩ **BUT THE FLOOR IS ASKED FIRST, AND SEPARATELY — SPEC §3.1.** `reason_class_matches`
236        // answers "could this rule apply?", and its absence/empty arm floors at `unresolved` so a
237        // hole nobody classified never slips out of a filter that names its own class. That is the
238        // right fail-closed default for a MATCHER and the WRONG basis for a FIRING: read as grounds
239        // to emit a violation it asserts a reason NOBODY RECORDED. The two questions shared this one
240        // helper safely only while the report route's refusal short-circuited before `gate()` ran;
241        // `8b97e5c` removed that short-circuit (correctly — a certain violation must reach the
242        // document) and the identical constant, on identical data, became a FABRICATION.
243        //
244        // MEASURED 2026-07-28, `deny Unknown[unresolved] app.opaque` over an entry with `inferred:
245        // ["Unknown"]` and no `direct`, no `unknownWhy`, no `calls`: exit 1 with a violation record
246        // in `--gate-json`, for a function whose determinable class set is EMPTY. The record was
247        // self-refuting — it carried no `reasonClass` key at all, because the floor exists only
248        // inside the predicate and never in the data.
249        //
250        // So the three-way split. NOT determinable ⇒ **WITHHELD**: the hit is dropped AND the pair
251        // rides out to the caller, because dropping it silently is the mirror defect (a narrowed
252        // filter tolerating for lack of evidence is the fail-open this whole rung exists to close).
253        // Determinable ⇒ the shared matcher decides, unchanged, and the `Some(cs)` arm it lands on
254        // is the only one a firing may rest on.
255        //
256        // THE MIRROR IS PINNED, because this is where an under-report gets introduced: an entry
257        // whose `unresolved` is INHERITED — a `calls` edge to a reasonless direct `Unknown` — has a
258        // determinable set of `{unresolved}` (contributed at the ENTRY, before the fixpoint) and
259        // MUST still fire. That is `R1_EXPECT["unresolved"]`'s `app.a_reasonless_only`, and
260        // `a_withheld_unknown_filter_does_not_take_the_inherited_one_with_it` beside it.
261        let classes = reason_classes;
262        let determinable = classes.is_some_and(|cs| !cs.is_empty());
263        if !determinable {
264            hits.retain(|e| *e != "Unknown");
265            withheld.push("Unknown");
266        } else if !reason_class_matches(classes, &want) {
267            hits.retain(|e| *e != "Unknown");
268        }
269    }
270    // Net destination-class: a `deny Net[dest…]` (non-empty filter) keeps its Net hit ONLY for a fn
271    // reaching one of those destination classes; else tolerate (only asserted-safe destinations).
272    // Fail-closed: a masked surface / a Net with no visible host is unknown-host (net_classes_of).
273    //
274    // ⟨0.24⟩ SAME THREE-WAY SPLIT AS THE REASON FILTER, and this side is where the shape is easiest
275    // to see because it never fabricated: with no destination classes to read, `any()` over the
276    // empty set is false and the Net hit was DROPPED — the *other* half of the same defect, an
277    // absence-keyed relaxation of a fail-closed gate. Silently tolerating and silently charging are
278    // the two ways to answer a question the evidence cannot settle; WITHHOLDING is the third, and
279    // the only one that stays true. Costs nothing on a signature this engine produced:
280    // `net_classes_of` floors every Net-bearing fn at `unknown-host`, so an empty set here means
281    // "this producer did not carry the field", never "this function reaches nothing".
282    if hits.contains(&"Net") && !r.net_classes.is_empty() {
283        let fn_net = net_classes;
284        if fn_net.is_empty() {
285            hits.retain(|e| *e != "Net");
286            withheld.push("Net");
287        } else if !fn_net.iter().any(|c| r.net_classes.contains(c)) {
288            hits.retain(|e| *e != "Net");
289        }
290    }
291    RuleHits { hits, withheld }
292}
293
294
295/// Apply a parsed §6.2 policy to an already-accumulated signature. THE ONLY matching code in the stable
296/// toolchain — `candor-scan --policy` and `candor-query gate --report` both land here, which is what
297/// makes "the same verdict from the same signature" a property of the code rather than of two
298/// consistent authors. Returns the violations, sorted by (rule, detail), AND the withheld pairs.
299pub fn gate<E: AsRef<str> + Ord>(p: &ParsedPolicy, gi: &GateInput<E>) -> GateOutcome {
300    let empty: BTreeSet<E> = BTreeSet::new();
301    let no_classes: Vec<String> = Vec::new();
302    let mut out = Vec::new();
303    let mut withheld: Vec<Withheld> = Vec::new();
304    // ONE VERDICT PER (rule, function), whatever the caller's enumeration. `all` is a list of UNITS on
305    // the scan route, and two units can share one qualified name — `#[cfg(unix)] fn f` beside
306    // `#[cfg(not(unix))] fn f` is the everyday case. Their signatures were already merged into one
307    // `inferred` entry keyed by that name, so the gate saw ONE signature and reported it TWICE: two
308    // byte-identical `GateViolation` records, an inflated `N policy violation(s)` count, and a
309    // `--gate-json` document that could not be equal to the one the ⟨0.24⟩ report route produces (a
310    // report is keyed by name, so the duplicate is not reachable there). FOUND BY the §3.1 byte-equality
311    // obligation, on 15 of 90 rows over ebman/pgman/the candor workspace — which is the whole argument
312    // for the verb: no end-to-end test could have separated this from a classifier defect.
313    let mut seen_fn: std::collections::HashSet<&str> = std::collections::HashSet::new();
314    let accepting = !p.accepts.is_empty() || p.stamp_rule_keys;
315    for q in gi.all {
316        // ⟨0.32⟩ the key identifies the unit; the NAME is what a human reads and a scope matches.
317        let disp_q = gi.disp(q);
318        if !seen_fn.insert(q.as_str()) {
319            continue;
320        }
321        let inf = gi.inferred.get(q).unwrap_or(&empty);
322        // Materialized ONCE per function rather than per (function, rule): `rule_hits` is generic over
323        // nothing, so the two routes' effect representations (`&'static str` interned / `String` off the
324        // wire) converge here instead of inside the matcher.
325        let effs: Vec<&str> = inf.iter().map(AsRef::as_ref).collect();
326        // AS-EFF-006 — deny/pure: forbidden effects in the transitive set.
327        for r in &p.rules {
328            if let Some(s) = &r.scope {
329                if !scope_matches(gi.disp(q), s) {
330                    continue;
331                }
332            }
333            // ONE implementation of the firing decision, shared with the provable-purity disclosure
334            // ([`crate::policy::unverified_hole_rule`]) — see [`rule_hits`] for what the second copy cost.
335            let RuleHits { hits, withheld: wh } = rule_hits(
336                r,
337                &effs,
338                gi.reason_classes.get(q),
339                gi.net_classes.get(q).map(Vec::as_slice).unwrap_or(&no_classes),
340            );
341            for filter in wh {
342                withheld.push(Withheld { rule: r.raw.clone(), func: gi.disp(q).to_string(), filter });
343            }
344            if !hits.is_empty() {
345                // §6.2: when Unknown is denied, report ALL reason classes on the fn (transitive), so the
346                // consumer sees every reason the strict gate bit — not just the class the rule matched.
347                let reason_class = if hits.contains(&"Unknown") {
348                    gi.reason_classes.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default()
349                } else {
350                    Vec::new()
351                };
352                // ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
353                let net_class = if hits.contains(&"Net") {
354                    gi.net_classes.get(q).cloned().unwrap_or_default()
355                } else {
356                    Vec::new()
357                };
358                out.push(GateViolation {
359                    rule: "AS-EFF-006".into(),
360                    func: gi.disp(q).to_string(),
361                    // ⟨0.32⟩ THE UNIT, beside the name — SPEC §2. Two members of a workspace violating
362                    // one rule under one name produced two byte-identical rows until this line.
363                    hash: gi.unit(q).to_string(),
364                    effects: hits.iter().map(|s| s.to_string()).collect(),
365                    detail: format!("`{disp_q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw),
366                    reason_class,
367                    net_class,
368                    origin: String::new(),
369                    // ⟨0.41⟩ the producing LINE, for an `accept` entry to name. Computed only when the policy has
370                    // an entry, so a policy without one pays nothing and a row never carries a key it cannot use.
371                    rule_key: if accepting { crate::policy::rule_key(&r.raw) } else { String::new() },
372                });
373            }
374        }
375        // AS-EFF-008 — literal allowlists over the transitive literal surfaces.
376        for r in &p.allow_rules {
377            if let Some(s) = &r.scope {
378                if !scope_matches(gi.disp(q), s) {
379                    continue;
380                }
381            }
382            if !inf.iter().any(|e| e.as_ref() == r.effect) {
383                continue;
384            }
385            let lits = match r.effect {
386                // `Llm` ⟨0.13⟩ rides the Net host surface (SPEC §1) — `allow Llm <host>` certifies the same
387                // captured hosts as `allow Net`, restricted to the MODEL hosts (a model call's host WAS
388                // captured as a Net literal). Matches candor-java's checkAllowlist("Llm", hostFixpoint, …).
389                "Net" | "Llm" => gi.hosts.get(q),
390                "Exec" => gi.cmds.get(q),
391                "Db" => gi.tables.get(q),
392                _ => gi.paths.get(q),
393            };
394            // An INCOMPLETE surface (a structurally-invisible reach) can't be certified even with visible
395            // hosts — else a benign literal masks the invisible forbidden endpoint (the masking evasion).
396            // `Llm` keys off the NET incompleteness (it rides the Net host literal): a runtime/masked model
397            // host that fails-closes Net must fail-close `allow Llm` too (incompleteAsLlm in candor-java).
398            let inc_key = if r.effect == "Llm" { "Net" } else { r.effect };
399            let surface_incomplete =
400                gi.surface_incomplete.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == inc_key));
401            match lits {
402                Some(ls) if !ls.is_empty() && !surface_incomplete => {
403                    let bad: Vec<&str> =
404                        ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
405                    if !bad.is_empty() {
406                        out.push(GateViolation {
407                            rule: "AS-EFF-008".into(),
408                            func: gi.disp(q).to_string(),
409                            hash: gi.unit(q).to_string(),   // ⟨0.32⟩ SPEC §2 — every verdict row
410                            effects: vec![r.effect.to_string()],
411                            detail: format!("`{disp_q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw),
412                            ..Default::default()
413                        });
414                    }
415                }
416                _ => out.push(GateViolation {
417                    rule: "AS-EFF-008".into(),
418                    func: gi.disp(q).to_string(),
419                    hash: gi.unit(q).to_string(),   // ⟨0.32⟩ SPEC §2 — every verdict row
420                    effects: vec![r.effect.to_string()],
421                    detail: format!("`{disp_q}` performs {} with no visible literal — the surface cannot be certified: `{}`", r.effect, r.raw),
422                    ..Default::default()
423                }),
424            }
425        }
426        // AS-EFF-009 — layering: no fn in scope A may transitively reach scope B.
427        for r in &p.layer_rules {
428            if !scope_matches(gi.disp(q), &r.from) {
429                continue;
430            }
431            let mut seen: BTreeSet<&str> = BTreeSet::new();
432            let mut stack: Vec<&str> =
433                gi.calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
434            let mut hit: Option<&str> = None;
435            while let Some(n) = stack.pop() {
436                if !seen.insert(n) {
437                    continue;
438                }
439                if scope_matches(gi.disp(n), &r.to) {
440                    hit = Some(n);
441                    break;
442                }
443                if let Some(cs) = gi.calls.get(n) {
444                    stack.extend(cs.iter().map(String::as_str));
445                }
446            }
447            if let Some(h) = hit {
448                out.push(GateViolation {
449                    rule: "AS-EFF-009".into(),
450                    func: gi.disp(q).to_string(),
451                    hash: gi.unit(q).to_string(),   // ⟨0.32⟩ SPEC §2 — every verdict row
452                    effects: Vec::new(), // a layer-flow has no single effect
453                    detail: format!("`{disp_q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw),
454                    rule_key: if accepting { crate::policy::rule_key(&r.raw) } else { String::new() },
455                    ..Default::default()
456                });
457            }
458        }
459        // ⟨0.29⟩ AS-EFF-011 — `only A -> B …`: a fn in A may reach A and the listed scopes, NOTHING else.
460        //
461        // The same walk as `forbid` above with the test INVERTED, and the inversion is the point rather
462        // than the code. `forbid` fails OPEN — what you did not prohibit is permitted — so a leaf package
463        // can only be protected by enumerating what it must not reach, and that list does not cover a
464        // package added tomorrow. `only` fails SAFE: the dependency you forgot to permit is a violation,
465        // loudly, on the day it appears.
466        //
467        // THE WALK STOPS AT A PERMITTED SCOPE. A permitted callee's own dependencies are governed by the
468        // rules about IT; descending past it would make `only` demand the transitive closure of everything
469        // you permit, which is the same enumeration-that-rots one level down. `from` IS descended through
470        // — a fn in A calling another fn in A that reaches infra is still A reaching infra.
471        for r in &p.only_rules {
472            if !scope_matches(gi.disp(q), &r.from) {
473                continue;
474            }
475            let mut seen: BTreeSet<&str> = BTreeSet::new();
476            let mut stack: Vec<&str> =
477                gi.calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
478            let mut hit: Option<&str> = None;
479            while let Some(n) = stack.pop() {
480                if !seen.insert(n) {
481                    continue;
482                }
483                // ⟨0.29⟩ EXACT segment match on a PERMITTED scope — see `scope_matches_permitted`. The
484                // shared prefix matcher is fail-CLOSED for every other rule kind and fail-OPEN here.
485                if r.to.iter().any(|t| scope_matches_permitted(gi.disp(n), t)) {
486                    continue; // permitted, and its own callees are not this rule's business
487                }
488                if !scope_matches(gi.disp(n), &r.from) {
489                    hit = Some(n);
490                    break;
491                }
492                if let Some(cs) = gi.calls.get(n) {
493                    stack.extend(cs.iter().map(String::as_str));
494                }
495            }
496            if let Some(h) = hit {
497                out.push(GateViolation {
498                    // ⟨0.29⟩ ITS OWN CODE, not `forbid`'s. A rule code is the handle a CI suppression, a
499                    // dashboard link and an alert filter key on, and these two are opposite constructs —
500                    // must-not-reach versus must-be-on-the-list. Sharing 009 would make every existing
501                    // `forbid` suppression silently start muting `only` violations its author never
502                    // accepted: a fail-open change to an operator's config, made by us and invisible to
503                    // them, which is the argument this form is built on turned on the tool.
504                    rule: "AS-EFF-011".into(),
505                    func: gi.disp(q).to_string(),
506                    hash: gi.unit(q).to_string(),   // ⟨0.32⟩ SPEC §2 — every verdict row
507                    effects: Vec::new(),
508                    detail: format!(
509                        "`{disp_q}` reaches `{h}`, which this permission rule does not permit: `{}`",
510                        r.raw
511                    ),
512                    ..Default::default()
513                });
514            }
515        }
516    }
517    // Sort by (rule, detail) — identical order to the old rendered-line sort (the "[rule] detail" render
518    // puts the constant '[' first and all AS-EFF codes are same-length), without allocating two Strings
519    // per comparison.
520    //
521    // ⟨0.32⟩ …AND THEN BY UNIT (SPEC §2). Two units sharing a name produce rows whose `rule` and
522    // `detail` are identical — `detail` is built from the DISPLAY name — so the pair tied and their
523    // order was whatever `all` happened to hold. §3.3.1 makes the order part of the byte-equality
524    // between the two routes, and the two routes accumulate in different orders. The same key is
525    // applied again in `candor_report`'s verdict writers, where the SCAN route's cross-member
526    // concatenation is re-sorted.
527    out.sort_by(|a, b| {
528        (a.rule.as_str(), a.detail.as_str(), a.hash.as_str())
529            .cmp(&(b.rule.as_str(), b.detail.as_str(), b.hash.as_str()))
530    });
531    // Deterministic for the same reason the violations are: a disclosure a consumer diffs between runs
532    // must not reorder because a HashMap iterated differently.
533    withheld.sort_by(|a, b| (&a.rule, &a.func).cmp(&(&b.rule, &b.func)));
534    withheld.dedup();
535    // ⟨0.27⟩ ZERO-MATCH DISCLOSURE. Counted over the SAME key set the gate iterated, so "bound nothing"
536    // means here exactly what it means to the gate. A `deny`/`pure` with NO scope applies to every
537    // function and so can never be this kind of typo — excluded. A layer rule counts a match on either
538    // endpoint, over the call-graph keys it binds across.
539    let mut zero: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
540    for r in &p.rules {
541        if r.scope.is_some() {
542            zero.entry(r.raw.as_str()).or_insert(0);
543        }
544    }
545    for r in &p.layer_rules {
546        zero.entry(r.raw.as_str()).or_insert(0);
547    }
548    // SOUNDNESS R952 — …AND A SCOPED `allow`. The clause's headline is "a rule whose SCOPE matches no
549    // function", with no form named, but this pass enrolled only `deny`/`pure`/`forbid`/`only`: measured,
550    // `allow Net in exec817::f ok.example` (a crate-qualified scope, which binds nothing here) printed
551    // `policy ✓`, exit 0, and no disclosure — over a `f` that connects to a caller-chosen host, which the
552    // crate-relative `allow Net in f ok.example` fails with AS-EFF-008. An `allow` is a certification, so
553    // one that binds nothing is the MORE dangerous typo: the operator believes a surface was checked. A
554    // SCOPELESS `allow` binds every function by construction and stays exempt, like a scopeless `deny`.
555    for r in &p.allow_rules {
556        if r.scope.is_some() {
557            zero.entry(r.raw.as_str()).or_insert(0);
558        }
559    }
560    // ⟨0.29⟩ an `only` rule binds nothing when NEITHER endpoint names anything in this tree — the same
561    // typo channel `forbid` has, and the more dangerous one to leave silent: a `forbid` that binds
562    // nothing merely fails to prohibit, while an `only` that binds nothing withholds a promise the
563    // operator believes they made.
564    for r in &p.only_rules {
565        zero.entry(r.raw.as_str()).or_insert(0);
566    }
567    if !zero.is_empty() {
568        let mut names: std::collections::BTreeSet<&str> =
569            gi.all.iter().map(|q| q.as_str()).collect();
570        names.extend(gi.calls.keys().map(String::as_str));
571        for n in names {
572            for r in &p.rules {
573                if let Some(s) = &r.scope {
574                    if scope_matches(gi.disp(n), s) {
575                        *zero.entry(r.raw.as_str()).or_insert(0) += 1;
576                    }
577                }
578            }
579            // R952 — the SAME matcher the AS-EFF-008 loop above skips on, so "bound" means here what
580            // it means to the allowlist check.
581            for r in &p.allow_rules {
582                if let Some(s) = &r.scope {
583                    if scope_matches(gi.disp(n), s) {
584                        *zero.entry(r.raw.as_str()).or_insert(0) += 1;
585                    }
586                }
587            }
588            for r in &p.layer_rules {
589                if scope_matches(gi.disp(n), &r.from) || scope_matches(gi.disp(n), &r.to) {
590                    *zero.entry(r.raw.as_str()).or_insert(0) += 1;
591                }
592            }
593            // ON `from` ONLY, and deliberately NOT on either endpoint the way a `forbid` counts. A
594            // `forbid`'s subject is the pair; an `only`'s subject is `from` — it is a promise ABOUT that
595            // scope — so a rule whose destinations all exist while its `from` names nothing has bound
596            // nothing at all, and is exactly the typo that leaves an operator believing a leaf is
597            // protected. Counting the destinations would hide it behind a scope that happens to resolve.
598            for r in &p.only_rules {
599                if scope_matches(gi.disp(n), &r.from) {
600                    *zero.entry(r.raw.as_str()).or_insert(0) += 1;
601                }
602            }
603        }
604    }
605    let zero_match: Vec<String> = zero
606        .iter()
607        .filter(|(_, c)| **c == 0)
608        .map(|(raw, _)| raw.to_string())
609        .collect();
610    // R301 — the OVER-bound counterpart of `zero_match`, from the same counting pass. Recomputing the
611    // names rather than accumulating them above keeps the hot loop allocation-free: this branch runs
612    // only for rules already known to have bound more than one function, which on a real policy is
613    // approximately never.
614    let mut multi_match: Vec<(String, Vec<String>)> = Vec::new();
615    for r in &p.rules {
616        let Some(sc) = &r.scope else { continue };
617        if sc.contains("::") {
618            continue; // a LAYER scope is meant to bind many — see the field's doc
619        }
620        if zero.get(r.raw.as_str()).copied().unwrap_or(0) < 2 {
621            continue;
622        }
623        let mut names: std::collections::BTreeSet<&str> =
624            gi.all.iter().map(|q| q.as_str()).collect();
625        names.extend(gi.calls.keys().map(String::as_str));
626        let bound: Vec<String> = names
627            .into_iter()
628            .filter(|n| scope_matches(gi.disp(n), sc))
629            .map(|n| n.to_string())
630            .collect();
631        if bound.len() > 1 {
632            multi_match.push((r.raw.clone(), bound));
633        }
634    }
635    multi_match.sort();
636    multi_match.dedup();
637    GateOutcome { violations: out, withheld, zero_match, multi_match }
638}