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, ParsedPolicy, PolicyRule};
19use candor_report::GateViolation;
20use std::collections::{BTreeSet, HashMap};
21
22/// ⟨0.20⟩ The `Net` destination classes an fn reaches (transitive) — the SINGLE derivation shared by the
23/// report's `netClass` field (candor-scan's writer) and the gate: an exact host-literal match
24/// ([`crate::net_dest_class`]) for the visible hosts, plus the fail-closed `unknown-host` when the Net
25/// surface is masked (`incomplete` has Net) OR carries no visible host (a runtime endpoint). Call only
26/// for an fn known to have Net; returns sorted.
27///
28/// It lives beside the gate rather than in the scanner because the report field and the gate filter MUST
29/// be the same set: `gate --report` reads `netClass` off the wire and the scan derives it here, and the
30/// §3.1 ⟨0.24⟩ byte-equivalence obligation is exactly the claim that those two agree.
31pub fn net_classes_of<E: AsRef<str> + Ord>(
32    q: &str,
33    hostsacc: &HashMap<String, BTreeSet<String>>,
34    incompleteacc: &HashMap<String, BTreeSet<E>>,
35    partners: &BTreeSet<String>,
36) -> Vec<String> {
37    let mut classes: BTreeSet<String> = hostsacc
38        .get(q)
39        .into_iter()
40        .flatten()
41        .map(|h| crate::net_dest_class(h, partners).to_string())
42        .collect();
43    let masked = incompleteacc.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == "Net"));
44    let no_hosts = hostsacc.get(q).map(|s| s.is_empty()).unwrap_or(true);
45    if masked || no_hosts {
46        classes.insert("unknown-host".to_string());
47    }
48    classes.into_iter().collect()
49}
50
51/// ⟨0.24⟩ THE GATE'S INPUT — one signature per function, every field already TRANSITIVE.
52///
53/// `E` is the effect-name representation: `&'static str` on the scan route (the classifier's interned
54/// vocabulary) and `String` on the report route (the wire's names, taken VERBATIM — a report naming an
55/// effect this build's vocabulary does not list must still trip a `pure` rule, so the names are never
56/// filtered through a known-effect allowlist on the way in).
57pub struct GateInput<'a, E: AsRef<str> + Ord> {
58    /// Every function the gate ranges over, in the caller's order.
59    pub all: &'a [String],
60    /// Per fn, the TRANSITIVE effect set — the model's `S`, with candor's `Unknown` marker carried as a
61    /// member (this engine's encoding of `D ≠ ∅`).
62    pub inferred: &'a HashMap<String, BTreeSet<E>>,
63    /// The call graph AS-EFF-009 walks.
64    pub calls: &'a HashMap<String, BTreeSet<String>>,
65    /// Per fn, the TRANSITIVE literal surface AS-EFF-008 certifies against.
66    pub hosts: &'a HashMap<String, BTreeSet<String>>,
67    pub cmds: &'a HashMap<String, BTreeSet<String>>,
68    pub paths: &'a HashMap<String, BTreeSet<String>>,
69    pub tables: &'a HashMap<String, BTreeSet<String>>,
70    /// Per fn, the effects whose literal surface is structurally INCOMPLETE — the AS-EFF-008 fail-closed
71    /// marker, without which a benign visible literal masks an invisible forbidden endpoint.
72    pub surface_incomplete: &'a HashMap<String, BTreeSet<E>>,
73    /// Per fn, the TRANSITIVE reason-class tokens — the model's `D` (§6.2 ⟨0.19⟩). The Unknown EFFECT
74    /// propagates along the call graph, so its REASON must too: else `deny E Unknown[reflect]` at a
75    /// caller inheriting Unknown from a reflect-caused callee sees no class and does NOT fire.
76    pub reason_classes: &'a HashMap<String, BTreeSet<String>>,
77    /// Per `Net`-bearing fn, its ⟨0.20⟩ destination classes, ALREADY derived — by [`net_classes_of`] on
78    /// the scan route, read verbatim from the report's `netClass` on the report route. Absent ⇒ empty.
79    pub net_classes: &'a HashMap<String, Vec<String>>,
80}
81
82/// ⟨0.24⟩ ONE `(rule, function)` THE GATE COULD NOT EVALUATE — SPEC §3.1: *"a rule FIRES on a function
83/// only where the match is evidenced by that function's own entry, and is WITHHELD exactly where it is
84/// not. Withholding is per `(rule, function)`, never whole-policy."*
85///
86/// A withheld pair is NOT a tolerated one. Tolerating means the evidence was read and did not match;
87/// withholding means there was no evidence to read, and the two must not arrive at a consumer wearing the
88/// same face. The caller decides the disposition — a violation elsewhere dominates (exit 1, disclose), a
89/// sole withholding is a refusal (exit 2) — but it can only do that if the fact reaches it, which is why
90/// this rides out of [`gate`] beside the violations instead of being logged here.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Withheld {
93    /// The rule's source line, verbatim (`PolicyRule::raw`).
94    pub rule: String,
95    /// The function the rule could not be evaluated ON. The same rule may fire on another.
96    pub func: String,
97    /// Which narrowing filter had nothing to read — `"Unknown"` or `"Net"`.
98    pub filter: &'static str,
99}
100
101/// ⟨0.24⟩ What [`gate`] returns: the violations it is SURE of, and the `(rule, function)` pairs it
102/// WITHHELD. Both halves travel, because the verdict is both (SPEC §3.1).
103#[derive(Debug, Default)]
104pub struct GateOutcome {
105    /// Sorted by (rule, detail).
106    pub violations: Vec<GateViolation>,
107    /// Sorted by (rule, func). Empty on every policy whose filters the signature can answer.
108    pub withheld: Vec<Withheld>,
109}
110
111/// ⟨0.24⟩ What one §6.2 `deny`/`pure` rule DOES to one function's signature — see [`rule_hits`].
112pub struct RuleHits<'a> {
113    /// The effects this rule CHARGES on this function, after both narrowing filters. Empty ⇒ the rule
114    /// does not fire here, which is what the disclosure calls PASSING.
115    pub hits: Vec<&'a str>,
116    /// The filters — `"Unknown"` / `"Net"` — that had no evidence to read, in that order. The hit was
117    /// dropped from `hits` AND the fact rides out, because dropping it silently is the mirror defect.
118    pub withheld: Vec<&'static str>,
119}
120
121/// ⟨0.24⟩ WHAT ONE §6.2 `deny`/`pure` RULE DOES TO ONE FUNCTION'S SIGNATURE — the firing decision,
122/// extracted so it has exactly one implementation.
123///
124/// **WHY IT IS A FUNCTION NOW.** It was inline in [`gate`], and the provable-purity disclosure
125/// ([`crate::policy::unverified_hole_rule`]) carried its own second copy that asked a coarser question:
126/// "does the rule NAME an effect this function has?", computed from `r.effects` alone. That copy could
127/// not see a narrowing filter, so it disagreed with the gate on exactly the rules the ⟨0.24⟩ rung added
128/// — and the disagreement ran the LOSING way. A hole is a function that PASSES its rule while `Unknown`,
129/// so a rule the gate TOLERATES (`deny Unknown[reflect]` over an `indirect` hole) was read by the
130/// disclosure as a violation-that-isn't and dropped from the output: `unverified` printed **"every
131/// function in a pure/deny layer is PROVABLY clean ✓"** over a function the gate had just declined to
132/// clear. MEASURED 2026-07-28 on a one-function crate; reachable with NO alias in play, one layer below
133/// the alias-widening defect `ea0df4f` closed.
134///
135/// The caller does the SCOPE test and owns the disposition of `withheld`; this answers only "given that
136/// this rule governs this function, what does it charge, and what could it not evaluate?".
137///
138/// `reason_classes` is the ACCUMULATED (post-fixpoint) class set — `None`/empty means the signature
139/// carries none, which is NOT determinable and is withheld, never floored. `net_classes` is the fn's
140/// ⟨0.20⟩ destination classes, likewise already derived.
141pub fn rule_hits<'a>(
142    r: &PolicyRule,
143    effects: &[&'a str],
144    reason_classes: Option<&BTreeSet<String>>,
145    net_classes: &[String],
146) -> RuleHits<'a> {
147    let mut withheld: Vec<&'static str> = Vec::new();
148    let mut hits: Vec<&str> = if r.effects.is_empty() {
149        // `pure` — every EFFECT, but NOT `Unknown`: the §4 trust marker is not an effect
150        // (AS-EFF-003's concern; `deny Unknown <scope>` is the explicit knob). The reference
151        // engine and the deep backend exclude it identically — this engine wrongly counted an
152        // Unknown-only fn as a `pure` violation until 2026-07-09 (a cross-engine verdict split
153        // on the same policy file).
154        effects.iter().copied().filter(|e| *e != "Unknown").collect()
155    } else {
156        effects.iter().copied().filter(|e| r.effects.contains(e)).collect()
157    };
158    // Reason-scoped Unknown: a `deny E Unknown[classes]` (non-empty filter) keeps its Unknown hit
159    // ONLY for a fn whose TRANSITIVE reason classes include one of those classes; else tolerate it
160    // (wrong reason-class). Concrete effects in `hits` are untouched — only Unknown is scoped.
161    if hits.contains(&"Unknown") && !r.unknown_classes.is_empty() {
162        let want: BTreeSet<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
163        // An Unknown with NO recorded reason is `unresolved` (conservative — stays in
164        // `[*]`/`[unresolved]`). THIS IS A NET, NOT A ROUTE. It is per FUNCTION and keys on the
165        // ABSENCE of a class set, so any other reason on the same function hides whatever it was
166        // covering — which is how a reasonless chained-dep `Unknown` went ungated on every consumer
167        // that also had a reason of its own. That case now CONTRIBUTES `unresolved` where the
168        // signature is BUILT (candor-scan's `reason_class_direct`; `gate_input_from_report` on the
169        // report route) instead of arriving here by absence. What is left for the absence arm is
170        // the RELEASE-mode gap: the writer's §4 invariant is a `debug_assert`, so a future path
171        // that puts `Unknown` into `direct` with no reason fails closed here rather than escaping
172        // the gate. Not dead — it is pinned by
173        // `reason_scoped_unknown_gate_fires_on_match_tolerates_mismatch`.
174        //
175        // ⟨0.24⟩ The rule itself lives in `crate::policy::reason_class_matches` because
176        // `unverified --class` must select over exactly the set this gate scopes over: a gate and
177        // the disclosure naming the holes that gate did not prove, disagreeing, is the defect.
178        //
179        // ⟨0.24⟩ **BUT THE FLOOR IS ASKED FIRST, AND SEPARATELY — SPEC §3.1.** `reason_class_matches`
180        // answers "could this rule apply?", and its absence/empty arm floors at `unresolved` so a
181        // hole nobody classified never slips out of a filter that names its own class. That is the
182        // right fail-closed default for a MATCHER and the WRONG basis for a FIRING: read as grounds
183        // to emit a violation it asserts a reason NOBODY RECORDED. The two questions shared this one
184        // helper safely only while the report route's refusal short-circuited before `gate()` ran;
185        // `8b97e5c` removed that short-circuit (correctly — a certain violation must reach the
186        // document) and the identical constant, on identical data, became a FABRICATION.
187        //
188        // MEASURED 2026-07-28, `deny Unknown[unresolved] app.opaque` over an entry with `inferred:
189        // ["Unknown"]` and no `direct`, no `unknownWhy`, no `calls`: exit 1 with a violation record
190        // in `--gate-json`, for a function whose determinable class set is EMPTY. The record was
191        // self-refuting — it carried no `reasonClass` key at all, because the floor exists only
192        // inside the predicate and never in the data.
193        //
194        // So the three-way split. NOT determinable ⇒ **WITHHELD**: the hit is dropped AND the pair
195        // rides out to the caller, because dropping it silently is the mirror defect (a narrowed
196        // filter tolerating for lack of evidence is the fail-open this whole rung exists to close).
197        // Determinable ⇒ the shared matcher decides, unchanged, and the `Some(cs)` arm it lands on
198        // is the only one a firing may rest on.
199        //
200        // THE MIRROR IS PINNED, because this is where an under-report gets introduced: an entry
201        // whose `unresolved` is INHERITED — a `calls` edge to a reasonless direct `Unknown` — has a
202        // determinable set of `{unresolved}` (contributed at the ENTRY, before the fixpoint) and
203        // MUST still fire. That is `R1_EXPECT["unresolved"]`'s `app.a_reasonless_only`, and
204        // `a_withheld_unknown_filter_does_not_take_the_inherited_one_with_it` beside it.
205        let classes = reason_classes;
206        let determinable = classes.is_some_and(|cs| !cs.is_empty());
207        if !determinable {
208            hits.retain(|e| *e != "Unknown");
209            withheld.push("Unknown");
210        } else if !reason_class_matches(classes, &want) {
211            hits.retain(|e| *e != "Unknown");
212        }
213    }
214    // Net destination-class: a `deny Net[dest…]` (non-empty filter) keeps its Net hit ONLY for a fn
215    // reaching one of those destination classes; else tolerate (only asserted-safe destinations).
216    // Fail-closed: a masked surface / a Net with no visible host is unknown-host (net_classes_of).
217    //
218    // ⟨0.24⟩ SAME THREE-WAY SPLIT AS THE REASON FILTER, and this side is where the shape is easiest
219    // to see because it never fabricated: with no destination classes to read, `any()` over the
220    // empty set is false and the Net hit was DROPPED — the *other* half of the same defect, an
221    // absence-keyed relaxation of a fail-closed gate. Silently tolerating and silently charging are
222    // the two ways to answer a question the evidence cannot settle; WITHHOLDING is the third, and
223    // the only one that stays true. Costs nothing on a signature this engine produced:
224    // `net_classes_of` floors every Net-bearing fn at `unknown-host`, so an empty set here means
225    // "this producer did not carry the field", never "this function reaches nothing".
226    if hits.contains(&"Net") && !r.net_classes.is_empty() {
227        let fn_net = net_classes;
228        if fn_net.is_empty() {
229            hits.retain(|e| *e != "Net");
230            withheld.push("Net");
231        } else if !fn_net.iter().any(|c| r.net_classes.contains(c)) {
232            hits.retain(|e| *e != "Net");
233        }
234    }
235    RuleHits { hits, withheld }
236}
237
238
239/// Apply a parsed §6.2 policy to an already-accumulated signature. THE ONLY matching code in the stable
240/// toolchain — `candor-scan --policy` and `candor-query gate --report` both land here, which is what
241/// makes "the same verdict from the same signature" a property of the code rather than of two
242/// consistent authors. Returns the violations, sorted by (rule, detail), AND the withheld pairs.
243pub fn gate<E: AsRef<str> + Ord>(p: &ParsedPolicy, gi: &GateInput<E>) -> GateOutcome {
244    let empty: BTreeSet<E> = BTreeSet::new();
245    let no_classes: Vec<String> = Vec::new();
246    let mut out = Vec::new();
247    let mut withheld: Vec<Withheld> = Vec::new();
248    // ONE VERDICT PER (rule, function), whatever the caller's enumeration. `all` is a list of UNITS on
249    // the scan route, and two units can share one qualified name — `#[cfg(unix)] fn f` beside
250    // `#[cfg(not(unix))] fn f` is the everyday case. Their signatures were already merged into one
251    // `inferred` entry keyed by that name, so the gate saw ONE signature and reported it TWICE: two
252    // byte-identical `GateViolation` records, an inflated `N policy violation(s)` count, and a
253    // `--gate-json` document that could not be equal to the one the ⟨0.24⟩ report route produces (a
254    // report is keyed by name, so the duplicate is not reachable there). FOUND BY the §3.1 byte-equality
255    // obligation, on 15 of 90 rows over ebman/pgman/the candor workspace — which is the whole argument
256    // for the verb: no end-to-end test could have separated this from a classifier defect.
257    let mut seen_fn: std::collections::HashSet<&str> = std::collections::HashSet::new();
258    for q in gi.all {
259        if !seen_fn.insert(q.as_str()) {
260            continue;
261        }
262        let inf = gi.inferred.get(q).unwrap_or(&empty);
263        // Materialized ONCE per function rather than per (function, rule): `rule_hits` is generic over
264        // nothing, so the two routes' effect representations (`&'static str` interned / `String` off the
265        // wire) converge here instead of inside the matcher.
266        let effs: Vec<&str> = inf.iter().map(AsRef::as_ref).collect();
267        // AS-EFF-006 — deny/pure: forbidden effects in the transitive set.
268        for r in &p.rules {
269            if let Some(s) = &r.scope {
270                if !scope_matches(q, s) {
271                    continue;
272                }
273            }
274            // ONE implementation of the firing decision, shared with the provable-purity disclosure
275            // ([`crate::policy::unverified_hole_rule`]) — see [`rule_hits`] for what the second copy cost.
276            let RuleHits { hits, withheld: wh } = rule_hits(
277                r,
278                &effs,
279                gi.reason_classes.get(q),
280                gi.net_classes.get(q).map(Vec::as_slice).unwrap_or(&no_classes),
281            );
282            for filter in wh {
283                withheld.push(Withheld { rule: r.raw.clone(), func: q.clone(), filter });
284            }
285            if !hits.is_empty() {
286                // §6.2: when Unknown is denied, report ALL reason classes on the fn (transitive), so the
287                // consumer sees every reason the strict gate bit — not just the class the rule matched.
288                let reason_class = if hits.contains(&"Unknown") {
289                    gi.reason_classes.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default()
290                } else {
291                    Vec::new()
292                };
293                // ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
294                let net_class = if hits.contains(&"Net") {
295                    gi.net_classes.get(q).cloned().unwrap_or_default()
296                } else {
297                    Vec::new()
298                };
299                out.push(GateViolation {
300                    rule: "AS-EFF-006".into(),
301                    func: q.clone(),
302                    effects: hits.iter().map(|s| s.to_string()).collect(),
303                    detail: format!("`{q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw),
304                    reason_class,
305                    net_class,
306                });
307            }
308        }
309        // AS-EFF-008 — literal allowlists over the transitive literal surfaces.
310        for r in &p.allow_rules {
311            if let Some(s) = &r.scope {
312                if !scope_matches(q, s) {
313                    continue;
314                }
315            }
316            if !inf.iter().any(|e| e.as_ref() == r.effect) {
317                continue;
318            }
319            let lits = match r.effect {
320                // `Llm` ⟨0.13⟩ rides the Net host surface (SPEC §1) — `allow Llm <host>` certifies the same
321                // captured hosts as `allow Net`, restricted to the MODEL hosts (a model call's host WAS
322                // captured as a Net literal). Matches candor-java's checkAllowlist("Llm", hostFixpoint, …).
323                "Net" | "Llm" => gi.hosts.get(q),
324                "Exec" => gi.cmds.get(q),
325                "Db" => gi.tables.get(q),
326                _ => gi.paths.get(q),
327            };
328            // An INCOMPLETE surface (a structurally-invisible reach) can't be certified even with visible
329            // hosts — else a benign literal masks the invisible forbidden endpoint (the masking evasion).
330            // `Llm` keys off the NET incompleteness (it rides the Net host literal): a runtime/masked model
331            // host that fails-closes Net must fail-close `allow Llm` too (incompleteAsLlm in candor-java).
332            let inc_key = if r.effect == "Llm" { "Net" } else { r.effect };
333            let surface_incomplete =
334                gi.surface_incomplete.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == inc_key));
335            match lits {
336                Some(ls) if !ls.is_empty() && !surface_incomplete => {
337                    let bad: Vec<&str> =
338                        ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
339                    if !bad.is_empty() {
340                        out.push(GateViolation {
341                            rule: "AS-EFF-008".into(),
342                            func: q.clone(),
343                            effects: vec![r.effect.to_string()],
344                            detail: format!("`{q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw),
345                            ..Default::default()
346                        });
347                    }
348                }
349                _ => out.push(GateViolation {
350                    rule: "AS-EFF-008".into(),
351                    func: q.clone(),
352                    effects: vec![r.effect.to_string()],
353                    detail: format!("`{q}` performs {} with no visible literal — the surface cannot be certified: `{}`", r.effect, r.raw),
354                    ..Default::default()
355                }),
356            }
357        }
358        // AS-EFF-009 — layering: no fn in scope A may transitively reach scope B.
359        for r in &p.layer_rules {
360            if !scope_matches(q, &r.from) {
361                continue;
362            }
363            let mut seen: BTreeSet<&str> = BTreeSet::new();
364            let mut stack: Vec<&str> =
365                gi.calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
366            let mut hit: Option<&str> = None;
367            while let Some(n) = stack.pop() {
368                if !seen.insert(n) {
369                    continue;
370                }
371                if scope_matches(n, &r.to) {
372                    hit = Some(n);
373                    break;
374                }
375                if let Some(cs) = gi.calls.get(n) {
376                    stack.extend(cs.iter().map(String::as_str));
377                }
378            }
379            if let Some(h) = hit {
380                out.push(GateViolation {
381                    rule: "AS-EFF-009".into(),
382                    func: q.clone(),
383                    effects: Vec::new(), // a layer-flow has no single effect
384                    detail: format!("`{q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw),
385                    ..Default::default()
386                });
387            }
388        }
389    }
390    // Sort by (rule, detail) — identical order to the old rendered-line sort (the "[rule] detail" render
391    // puts the constant '[' first and all AS-EFF codes are same-length), without allocating two Strings
392    // per comparison.
393    out.sort_by(|a, b| (a.rule.as_str(), a.detail.as_str()).cmp(&(b.rule.as_str(), b.detail.as_str())));
394    // Deterministic for the same reason the violations are: a disclosure a consumer diffs between runs
395    // must not reorder because a HashMap iterated differently.
396    withheld.sort_by(|a, b| (&a.rule, &a.func).cmp(&(&b.rule, &b.func)));
397    withheld.dedup();
398    GateOutcome { violations: out, withheld }
399}