Skip to main content

candor_classify/
policy.rs

1//! The canonical CANDOR_POLICY DSL parser (candor-spec SPEC §6.2).
2//!
3//! This is the **single** Rust implementation of the policy grammar — shared by the nightly dylint
4//! gate (`src/lib.rs`, AS-EFF-006/008/009) and the stable `candor-query` (`whatif`, and the
5//! `parsepolicy` dump the cross-impl conformance suite diffs against the JVM engine). Keeping one
6//! parser here is what makes "the gate means the same thing in every language" a fact rather than a
7//! hope: the Rust gate, the Rust pre-edit tool, and the cross-impl differential all read THIS code.
8//!
9//! Pure, stable Rust (string parsing only — no rustc types), so it lives beside the classifier.
10
11use crate::cap_from_name;
12use std::collections::{BTreeMap, BTreeSet};
13
14/// The honesty marker (SPEC §4). Denyable so `deny Unknown <scope>` forbids the *unverifiable* case.
15pub const UNKNOWN: &str = "Unknown";
16
17/// The NORMATIVE projection of a raw `unknown_why` reason onto a fixed, cross-engine reason CLASS
18/// (candor-spec REASON-SCOPED-UNKNOWN-DESIGN.md §1). Reason-scoped policies (`deny E Unknown[class]`)
19/// quantify over these classes, so the mapping MUST be identical in every engine — this mirrors the
20/// java reference `ReasonClass` (its `classify(String)` path, since rust emits raw string reasons). The
21/// class set is CLOSED (six members); a raw reason matching no pinned prefix maps to `Unresolved` —
22/// conservative: it stays in scope of any `Unknown[*]` / `Unknown[dynamic]` policy, never silently
23/// tolerated.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum ReasonClass {
26    /// reflection / metaprogramming
27    Reflect,
28    /// unresolved virtual/dynamic dispatch, same-name ambiguity, invokedynamic
29    Dispatch,
30    /// callback / closure / function-value / async-continuation indirection
31    Indirect,
32    /// FFI / native boundary
33    Native,
34    /// generic unresolvable call/import, AND the catch-all for any unrecognized raw reason
35    Unresolved,
36    /// analysis not wired up (fixable, not a real dynamic hole): missing-config / no-tsconfig
37    Setup,
38}
39
40impl ReasonClass {
41    /// The lowercase policy-facing token (`deny E Unknown[<token>]`).
42    pub fn token(self) -> &'static str {
43        match self {
44            ReasonClass::Reflect => "reflect",
45            ReasonClass::Dispatch => "dispatch",
46            ReasonClass::Indirect => "indirect",
47            ReasonClass::Native => "native",
48            ReasonClass::Unresolved => "unresolved",
49            ReasonClass::Setup => "setup",
50        }
51    }
52
53    /// Parse a policy-facing token back to a class; `None` if it names no class.
54    pub fn from_token(t: &str) -> Option<ReasonClass> {
55        Some(match t {
56            "reflect" => ReasonClass::Reflect,
57            "dispatch" => ReasonClass::Dispatch,
58            "indirect" => ReasonClass::Indirect,
59            "native" => ReasonClass::Native,
60            "unresolved" => ReasonClass::Unresolved,
61            "setup" => ReasonClass::Setup,
62            _ => return None,
63        })
64    }
65
66    /// Map a raw `unknown_why` reason to its normative class — prefix-based (raw reasons carry a
67    /// `kind:detail` shape, e.g. `dispatch:foo::Bar`), unrecognized → `Unresolved`. Byte-identical
68    /// intent to the java `ReasonClass.classify(String)`.
69    ///
70    /// ⟨0.24⟩ THIS IS THE ONLY PLACE THIS ENGINE HOLDS SPEC §4's KIND VOCABULARY. Every other reference
71    /// to a kind is either a raw string being emitted or the `dispatch:` prefix test in candor-query's
72    /// dispatch frontier; there is no typed kind enum here. §4's "AN ENGINE HOLDS THIS VOCABULARY TWICE,
73    /// AND THE HALVES DRIFT" paragraph records the JVM engine classifying `ambiguous` correctly HERE
74    /// while its typed `Kind` enum lacked the kind entirely — one token, two answers, inside one engine,
75    /// concealed precisely because this half was right. Holding it once is why that is not reachable in
76    /// this engine; a future typed representation must be added at the same commit as its control
77    /// (`off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all`).
78    ///
79    /// The five §4 kinds are `reflect`/`native`/`dispatch`/`callback`/`ambiguous`. `ambiguous` maps to
80    /// `dispatch` and rust is its only PRODUCER; `indy`/`task-handoff` are candor-java's migration kinds
81    /// and `dep:`/`dep-stale:` are swift's registered per-dependency-ENTRY kinds, reaching `Unresolved`
82    /// through the catch-all, which is the class §6.2 prescribes for them.
83    pub fn classify(why: &str) -> ReasonClass {
84        let w = why.trim().to_ascii_lowercase();
85        if w.starts_with("reflect") || w == "dynamicmemberlookup" {
86            ReasonClass::Reflect
87        } else if w.starts_with("native") {
88            ReasonClass::Native
89        } else if w.starts_with("callback") || w.starts_with("closure") || w.starts_with("task-handoff") {
90            ReasonClass::Indirect
91        } else if w.starts_with("dispatch") || w.starts_with("indy") || w.starts_with("ambiguous") {
92            ReasonClass::Dispatch
93        } else if w.starts_with("missing-config") || w.starts_with("no-tsconfig") || w.starts_with("no-node_modules") {
94            ReasonClass::Setup
95        } else {
96            ReasonClass::Unresolved
97        }
98    }
99
100    /// The `dynamic` alias — every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` (the
101    /// catch-all) so `Unknown[dynamic]` never under-gates. The design's recommended usable strict gate.
102    pub fn dynamic_set() -> BTreeSet<ReasonClass> {
103        [
104            ReasonClass::Reflect,
105            ReasonClass::Dispatch,
106            ReasonClass::Indirect,
107            ReasonClass::Native,
108            ReasonClass::Unresolved,
109        ]
110        .into_iter()
111        .collect()
112    }
113}
114
115/// SPEC §6.2 ⟨0.24⟩ — does a function's TRANSITIVE reason-class set intersect `want` (class tokens)?
116///
117/// THE `None`/EMPTY ARM IS THE FAIL-CLOSED NET, and it is why this lives here rather than being
118/// open-coded twice. §6.2: "a function whose `Unknown` carries no recorded reason CONTRIBUTES
119/// `unresolved` to its class set — so a narrowed filter never *silently* tolerates a hole it failed to
120/// classify." Read the other way round, `!contains ⇒ exclude` over an empty set drops the entry from
121/// EVERY filter including one naming its own class: a silent under-report wearing a filter.
122///
123/// `classes` is the ACCUMULATED (post-`propagate_str`) set, never the direct `unknownWhy`: a reason
124/// names a site in the function's OWN body (§4), so a function whose `Unknown` is purely inherited
125/// carries none, and matching against the direct field answers a different question. SHARED by the
126/// `deny E Unknown[class]` gate (candor-scan) and `unverified --class` (candor-query) so a gate and the
127/// disclosure explaining it cannot disagree about which holes a class names.
128///
129/// The empty arm is a NET, not a route: it keys on the ABSENCE of a class set, so any other reason on
130/// the same function hides whatever it was covering. The reasonless case that CAN co-occur with a
131/// reason — a direct `Unknown` the unit did not name — must therefore contribute `unresolved` into the
132/// DIRECT map before propagation (candor-scan's `unknown_via_dep`, candor-query's report-side signature),
133/// not arrive here by absence.
134pub fn reason_class_matches(classes: Option<&BTreeSet<String>>, want: &BTreeSet<&str>) -> bool {
135    match classes {
136        None => want.contains(ReasonClass::Unresolved.token()),
137        Some(cs) if cs.is_empty() => want.contains(ReasonClass::Unresolved.token()),
138        Some(cs) => cs.iter().any(|t| want.contains(t.as_str())),
139    }
140}
141
142/// Parse `unknown-alias <name> = <class,…>` lines from `.candor/config` text (⟨0.19⟩, SPEC §6.2) into a
143/// name→classes map. A name that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped (a
144/// config alias may not redefine a built-in), as is a definition naming no valid class. Byte-shape with the
145/// java reference `Config.addAlias`.
146pub fn parse_unknown_aliases(config_text: &str) -> std::collections::BTreeMap<String, BTreeSet<ReasonClass>> {
147    let mut out = std::collections::BTreeMap::new();
148    for raw in config_text.lines() {
149        let line = raw.split('#').next().unwrap_or("").trim();
150        if line.is_empty() {
151            continue;
152        }
153        let mut it = line.splitn(2, char::is_whitespace);
154        // Case-INSENSITIVE key match, like the config loaders in java/ts/swift (which lowercase the key) —
155        // a case-sensitive match here made `Unknown-Alias …` define an alias everywhere BUT rust (a four-way
156        // parse divergence; caught in review). The rest of the line (name + classes) stays case-sensitive.
157        if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("unknown-alias")) {
158            continue;
159        }
160        let val = it.next().unwrap_or("").trim();
161        let Some((name, classes)) = val.split_once('=') else {
162            eprintln!("candor: ignoring `unknown-alias` (want `unknown-alias <name> = <class,…>`): {val}");
163            continue;
164        };
165        let name = name.trim();
166        if name.is_empty() || name == "*" || name == "dynamic" || ReasonClass::from_token(name).is_some() {
167            eprintln!("candor: ignoring `unknown-alias` with reserved/empty name `{name}` (may not shadow `*`/`dynamic`/a class token)");
168            continue;
169        }
170        let mut set = BTreeSet::new();
171        let mut bad: Vec<&str> = Vec::new();
172        for cn in classes.split(',') {
173            let cn = cn.trim();
174            if cn.is_empty() {
175                continue;
176            }
177            if cn == "dynamic" {
178                set.extend(ReasonClass::dynamic_set());
179            } else if let Some(rc) = ReasonClass::from_token(cn) {
180                set.insert(rc);
181            } else {
182                bad.push(cn);
183            }
184        }
185        // ⟨0.24⟩ **AN UNRECOGNISED TOKEN REFUSES THE WHOLE DEFINITION** — SPEC §6.2 `be0b9a9`: the rule
186        // binds every policy value list, and *"the second is the sharper one: the typo is in the
187        // vocabulary the policy is written against rather than in the policy itself, and it fails open
188        // identically."*
189        //
190        // MEASURED: `unknown-alias corp = dispatch,nativ` → the DEFINITION silently became `{dispatch}`,
191        // so `deny Unknown[corp]` exited 0 over a native-caused hole that `= dispatch,native` catches.
192        // The alias resolved, `used_aliases` recorded it, the verdict named the config — every disclosure
193        // fired correctly ABOUT A DEFINITION THAT WAS NOT THE ONE ON DISK.
194        //
195        // REFUSING THE DEFINITION rather than minting a new error channel is what makes this fit: an
196        // alias that does not exist is one the policy's own `Unknown[<name>]` cannot resolve, so it lands
197        // on the `errors` path already there and the gate routes refuse with exit 2 — naming the token
198        // AND the accepted set, as §6.2 requires. It also keeps the blast radius honest: a config
199        // defining ten aliases, one of them typo'd and NONE of them mentioned by this policy, changed
200        // nothing, and turning that into a red gate would be the mirror over-reach. The refusal is
201        // triggered by USE, exactly like `used_aliases` (recorded at the point of use, for the same
202        // reason).
203        if !bad.is_empty() {
204            eprintln!(
205                "candor: REFUSING `unknown-alias {name}` — it names unrecognised reason-class(es) `{}` \
206                 (accepted: reflect, dispatch, indirect, native, unresolved, setup, plus `dynamic`). The \
207                 definition is refused WHOLE rather than narrowed to the tokens that parsed: keeping the \
208                 rest would silently redefine `{name}` as something narrower than the config says, and a \
209                 policy using it would gate less than it reads. `{name}` is now undefined, so a policy \
210                 naming it is a policy error (exit 2).",
211                bad.join("`, `")
212            );
213        } else if set.is_empty() {
214            eprintln!("candor: ignoring `unknown-alias {name}` — no valid reason-class");
215        } else {
216            out.insert(name.to_string(), set);
217        }
218    }
219    out
220}
221
222/// ⟨0.20⟩ Parse `net-partner <host>` lines from `.candor/config` (NET-DESTINATION-CLASS-DESIGN.md): the
223/// per-project set of business-partner hosts the `Net` destination-class classifier treats as
224/// `known-partner`. Multi-value (repeatable key); the value is host-normalized (`:port` stripped,
225/// lowercased) like `MODEL_HOSTS`. Case-insensitive key match, mirroring `parse_unknown_aliases` + the
226/// java/ts/swift config loaders. A partner is per-project — never a universal list.
227pub fn parse_net_partners(config_text: &str) -> BTreeSet<String> {
228    let mut out = BTreeSet::new();
229    for raw in config_text.lines() {
230        let line = raw.split('#').next().unwrap_or("").trim();
231        if line.is_empty() {
232            continue;
233        }
234        let mut it = line.splitn(2, char::is_whitespace);
235        if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("net-partner")) {
236            continue;
237        }
238        let val = it.next().unwrap_or("").trim();
239        if !val.is_empty() {
240            out.insert(host_part(val).to_ascii_lowercase());
241        }
242    }
243    out
244}
245
246/// Discover `.candor/config` text for a policy/scan anchored at `start`: `$CANDOR_CONFIG` if set + readable,
247/// else the nearest `.candor/config` walking UP from `start`, else `None`. Read-only + lenient (no
248/// process-exit — the caller decides fail-closed); used to resolve `unknown-alias` for the §6.2 gate +
249/// `parsepolicy` so both reflect the same checked-in config.
250pub fn discover_config_text(start: &std::path::Path) -> Option<String> {
251    discover_config(start).map(|(_, t)| t)
252}
253
254/// ⟨0.24⟩ As [`discover_config_text`], but ALSO the PATH the text came from, canonicalized.
255///
256/// **WHY THE PATH IS NOW LOAD-BEARING** (SPEC §3.1): a `.candor/config` supplying `unknown-alias`
257/// vocabulary can move a verdict 0→1, and discovery walks PARENT DIRECTORIES, so a file anywhere above
258/// the policy participates — ambient, and until now invisible in the output. *"A verdict changed by a
259/// file the operator cannot see named in the output is the ambient-input failure this whole format
260/// exists to refuse; the remedy is the same one used everywhere else here — not to forbid the input, but
261/// to make it impossible for it to act unnamed."* So the gate document NAMES it, and the path has to
262/// travel out of discovery for that to be possible.
263///
264/// CANONICALIZED because the two routes reach the same file from different working directories, and
265/// §3.1's byte-equality MUST is about the DOCUMENT: a relative path would differ between them for no
266/// reason other than where each was invoked.
267pub fn discover_config(start: &std::path::Path) -> Option<(std::path::PathBuf, String)> {
268    let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
269    if let Ok(p) = std::env::var("CANDOR_CONFIG") {
270        let p = std::path::PathBuf::from(p);
271        return std::fs::read_to_string(&p).ok().map(|t| (canon(&p), t));
272    }
273    let start = canon(start);
274    let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
275    while let Some(d) = cur {
276        let cand = d.join(".candor/config");
277        if cand.is_file() {
278            return std::fs::read_to_string(&cand).ok().map(|t| (canon(&cand), t));
279        }
280        cur = d.parent();
281    }
282    None
283}
284
285/// One `deny <Effect…> [scope]` / `pure <scope>` rule (AS-EFF-006). `effects` empty ⇒ a `pure` rule
286/// (ANY effect forbidden). `scope` is a path segment-scope the rule applies to (None = whole unit).
287#[derive(Debug, Clone)]
288pub struct PolicyRule {
289    pub effects: BTreeSet<&'static str>,
290    pub scope: Option<String>,
291    /// Reason-class filter on an `Unknown` membership (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒
292    /// `Unknown[*]` (any reason — the bare form); non-empty ⇒ the Unknown hit fires ONLY for a fn whose
293    /// (transitive) reason classes include one of these. Ignored when `effects` doesn't contain `Unknown`.
294    pub unknown_classes: BTreeSet<ReasonClass>,
295    /// Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
296    /// (any destination — the bare form); non-empty ⇒ the Net hit fires ONLY for a fn whose (transitive)
297    /// destination classes include one of these. Ignored when `effects` doesn't contain `Net`.
298    pub net_classes: BTreeSet<String>,
299    pub raw: String,
300}
301
302/// One `allow <Effect> [in <scope>] <literal>…` rule (AS-EFF-008). The effect is one of the four
303/// that carry a literal surface (`Net` hosts / `Exec` commands / `Fs` paths / `Db` tables); a
304/// function in `scope` performing it may reach ONLY the listed literals. Matching is
305/// effect-specific (`literal_allowed`).
306#[derive(Debug, Clone)]
307pub struct AllowRule {
308    pub effect: &'static str,
309    pub scope: Option<String>,
310    pub literals: BTreeSet<String>,
311    pub raw: String,
312}
313
314/// One `forbid <A> -> <B>` module-layering rule (AS-EFF-009): a function in scope `A` must not
315/// transitively call into scope `B`.
316#[derive(Debug, Clone)]
317pub struct LayerRule {
318    pub from: String,
319    pub to: String,
320    pub raw: String,
321}
322
323/// ⟨0.24⟩ ONE POLICY LINE THE PARSER DID NOT HONOUR AS WRITTEN (SPEC §3.1 `901f14d` / `195d45a`).
324///
325/// **THE DEFECT.** `parsepolicy` emitted **no `errors` key at all** (measured 2026-07-28 on the
326/// conformance battery: java 10, ts 4, rust 0, swift 0). Every one of these facts existed — they were
327/// printed to stderr as "ignoring policy rule …" — so the verb whose entire purpose is to let a consumer
328/// diff what an engine made of a policy answered the question with the not-honoured half deleted. Worse,
329/// it was INCONSISTENT with this engine's own gate: the gate refuses an unrecognised class token while
330/// the parse narrowed it silently, which is two answers to one question.
331///
332/// **`kind` IS A CLOSED SET, AND IT IS THE SPEC'S, NOT THE REFERENCE ENGINE'S.** `901f14d` pins four
333/// values: `reason-class/alias`, `Net destination-class`, `effect-name`, `rule-kind`. Measured, candor-java
334/// emits `forbid form`, `allow values` and `rule kind` (space, not hyphen) — three values outside the set
335/// and one spelling divergence — and candor-ts renames `kind`→`vocabulary` and `rule`→`where` and emits
336/// `accepted` as a PROSE STRING. This engine follows the clause: a line that names a rule keyword but does
337/// not form that keyword's rule formed no rule kind, so it is `rule-kind`.
338///
339/// `accepted` is an ARRAY OF TOKENS — the tokens that WOULD have been honoured in the position the bad one
340/// occupies. Empty where the position is open-ended (a host, a path), which is a fact about the grammar
341/// rather than a gap in the report.
342#[derive(Debug, Clone)]
343pub struct PolicyError {
344    /// One of [`PolicyError::KIND_REASON_CLASS`], [`PolicyError::KIND_NET_CLASS`],
345    /// [`PolicyError::KIND_EFFECT_NAME`], [`PolicyError::KIND_RULE_KIND`].
346    pub kind: &'static str,
347    /// The offending token, verbatim. Empty when the position was EMPTY (a missing arrow, an `allow` with
348    /// no values) — which is itself the finding.
349    pub token: String,
350    /// The tokens accepted in that position. Empty ⇒ the position takes an open-ended literal.
351    pub accepted: Vec<String>,
352    /// The raw policy line, verbatim.
353    pub rule: String,
354    /// The human sentence — the same text the stderr channel carries, so the two cannot disagree.
355    pub message: String,
356    /// ⟨0.24⟩ Does this error make the policy UNHONOURABLE, so every gate route must refuse (exit 2)?
357    ///
358    /// FATAL and REPORTED are different questions and this field is the only place they are told apart.
359    /// A dropped `nonsense line` is reported and survivable — the rest of the policy means what it says.
360    /// A rewritten `deny Unknown[dispatch,nativ]` is not: the rule that RAN is not the rule that was
361    /// written, and the direction that matters NARROWS it.
362    pub fatal: bool,
363}
364
365impl PolicyError {
366    pub const KIND_REASON_CLASS: &'static str = "reason-class/alias";
367    pub const KIND_NET_CLASS: &'static str = "Net destination-class";
368    pub const KIND_EFFECT_NAME: &'static str = "effect-name";
369    pub const KIND_RULE_KIND: &'static str = "rule-kind";
370}
371
372/// The rule kinds parsed from a CANDOR_POLICY file.
373#[derive(Default, Debug)]
374pub struct ParsedPolicy {
375    pub rules: Vec<PolicyRule>,
376    pub allow_rules: Vec<AllowRule>,
377    pub layer_rules: Vec<LayerRule>,
378    /// ⟨0.24⟩ POLICY ERRORS — a policy that cannot be honoured AS WRITTEN (SPEC §6.2). Non-empty ⇒ every
379    /// gate route MUST refuse: exit 2, the unreadable-policy posture. Not a warning list: the rules in
380    /// `rules` are what the text would mean if the error were tolerated, and tolerating it is the defect.
381    ///
382    /// Today the only member is an unrecognised reason-class/alias token in an `Unknown[…]` filter. The
383    /// asymmetry that used to justify a warning — "a dropped policy token leaves a WIDER rule standing,
384    /// so the failure is loud" — is false in the case that matters, and the false half is FAIL-OPEN:
385    ///
386    ///   - `deny Unknown[corp]` (sole unrecognised token) — the filter empties and the rule WIDENS to a
387    ///     bare `deny Unknown`, while the engine prints "ignoring policy rule" and then KEEPS and
388    ///     re-scopes it. Merely surprising, but a FALSE DISCLOSURE.
389    ///   - `deny Unknown[dispatch,nativ]` (a typo BESIDE valid tokens) — the token is dropped, the rule
390    ///     NARROWS to `[dispatch]`, and it stops gating native-caused holes entirely while the operator
391    ///     reads a gate that looks armed. **That is the fail-open, and it is the common case: a typo
392    ///     lands beside correct tokens far more often than alone.**
393    ///
394    /// A policy that cannot be honoured as written is not silently rewritten into a different policy.
395    ///
396    /// ⟨0.24⟩ THIS LIST NOW HOLDS EVERY LINE THE PARSER DID NOT HONOUR, fatal or not (SPEC §3.1
397    /// `195d45a`) — `parsepolicy` reports them all, and the gate routes refuse on
398    /// [`ParsedPolicy::fatal_messages`] alone. Widening the LIST without widening what REFUSES is the
399    /// whole of the change: a dropped `nonsense line` was always survivable and stays so.
400    pub errors: Vec<PolicyError>,
401    /// ⟨0.24⟩ The `.candor/config` `unknown-alias` definitions this policy actually resolved a token
402    /// through (SPEC §3.1) — **name → the reason-class TOKENS it expanded to**, not a bare name list.
403    /// Non-empty ⇒ a config file supplied vocabulary that PARTICIPATED in the verdict, and the
404    /// `--gate-json` document MUST name that file. Recorded at the point of USE, not from the alias map:
405    /// a config defining ten aliases none of which the policy mentions changed nothing, and naming it
406    /// would train the reader to ignore the field.
407    ///
408    /// ⟨0.24⟩ **THE VALUE TRAVELS WITH THE NAME, AND THAT IS A SPEC MUST** (§3.1, candor-spec `7f5b5ba`).
409    /// This engine shipped the bare name — as did java and swift — and candor-ts kept the map and won the
410    /// argument from the clause's OWN sentence: `configSources: [path]` is rejected there because *a
411    /// disclosure that names the source but not the content leaves the reader knowing they were affected
412    /// and not how*, and `["corp"]` fails that same test one level down. **`corp = reflect` and
413    /// `corp = reflect,native` gate DIFFERENTLY under one unchanged policy line**, so a reader given only
414    /// the name cannot tell which gate ran. The map is a strict superset — the keys recover the old array.
415    ///
416    /// Class TOKENS rather than `ReasonClass`, so the wire order is the token's alphabetical one (which
417    /// is what candor-ts's `[...set].sort()` produces) and not `ReasonClass`'s declaration order.
418    pub used_aliases: BTreeMap<String, BTreeSet<String>>,
419}
420
421impl ParsedPolicy {
422    /// ⟨0.24⟩ The messages of the errors that make the policy UNHONOURABLE — what every gate route
423    /// refuses on. Non-empty ⇒ exit 2, the unreadable-policy posture.
424    ///
425    /// Separate from `errors` because REPORTED and FATAL are different questions, and conflating them in
426    /// either direction is a defect: refusing on every dropped line would make `nonsense line` fail a
427    /// build, and reporting only the fatal ones is the silent narrowing this rung exists to close.
428    pub fn fatal_messages(&self) -> Vec<&str> {
429        self.errors.iter().filter(|e| e.fatal).map(|e| e.message.as_str()).collect()
430    }
431}
432
433/// The hostname part of a `host[:port]` literal, port stripped — so `api.stripe.com` in a rule accepts
434/// a reached `api.stripe.com:443`. IPv6-aware: a bracketed `[host]:port` yields the bracketed host, and
435/// a BARE IPv6 literal (>1 colon, no brackets) has no port to strip and is returned whole — a naive
436/// first-colon split collapsed every `2001:db8::*` to `2001`, so one allowed IPv6 accepted any address
437/// in that block (/code-review). A hostname/IPv4 `host` or `host:port` (≤1 colon) splits at the colon.
438pub fn host_part(h: &str) -> &str {
439    if let Some(rest) = h.strip_prefix('[') {
440        // `[ipv6]` or `[ipv6]:port` — the host is between the brackets.
441        return rest.split(']').next().unwrap_or(rest);
442    }
443    if h.matches(':').count() > 1 {
444        return h; // bare IPv6 literal — no port suffix to strip
445    }
446    h.split(':').next().unwrap_or(h)
447}
448
449/// The basename of a command (`/usr/bin/git` → `git`), so `allow Exec … git` accepts an absolute path.
450pub fn cmd_base(c: &str) -> &str {
451    c.rsplit(['/', '\\']).next().unwrap_or(c)
452}
453
454/// Whether an allowed path `a` covers a reached path `r` (SPEC §6.2: path-boundary-respecting prefix).
455/// A directory covers itself and everything beneath it, but NOT a sibling sharing a textual prefix
456/// (`/etc/app` ⊉ `/etc/apppwned`); a `..` that climbs out is never covered; absolute/relative are
457/// never conflated.
458pub fn fs_path_covered(a: &str, r: &str) -> bool {
459    if r.split(['/', '\\']).any(|c| c == "..") {
460        return false;
461    }
462    let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
463    if absolute(a) != absolute(r) {
464        return false;
465    }
466    let norm = |s: &str| -> Vec<String> {
467        s.split(['/', '\\'])
468            .filter(|c| !c.is_empty() && *c != ".")
469            .map(|c| c.to_string())
470            .collect()
471    };
472    let (ac, rc) = (norm(a), norm(r));
473    ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
474}
475
476/// Whether an allowed table entry `a` covers a reached table `r` (SPEC §6.2): case-insensitive
477/// exact match on the (possibly schema-qualified) name, or a `schema.*` entry covering every table
478/// in that schema. Strict on qualification — an allowed `entries` does NOT cover a reached
479/// `ledger.entries` (write both forms if your queries mix them); silent widening is the failure
480/// mode an allowlist exists to prevent.
481pub fn db_table_covered(a: &str, r: &str) -> bool {
482    let (a, r) = (a.to_lowercase(), r.to_lowercase());
483    if let Some(schema) = a.strip_suffix(".*") {
484        return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
485    }
486    a == r
487}
488
489/// Whether a reached literal is allowed under an effect-specific match (SPEC §6.2): `Net` host by
490/// name (port ignored), `Exec` command by basename, `Fs` path by boundary-respecting prefix,
491/// `Db` table by qualified name or `schema.*`.
492pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
493    match effect {
494        // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — matched by hostname like `Net`.
495        "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
496        "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
497        "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
498        "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
499        _ => allow.contains(reached),
500    }
501}
502
503/// Split a function name (or scope) into PATH SEGMENTS on either separator. Reports reach the Rust gate
504/// AND `candor-query` from BOTH the Rust engines (`::`-separated names) and the JVM/Swift/TS engines
505/// (`.`-separated names — `candor-query` is explicitly built to read them). Segmenting on `::` ALONE
506/// left a scoped `deny`/`pure` rule silently INERT on a dotted name: the scope matched nothing, so
507/// `whatif` returned a false green on the security boundary (gate-evasion). The JVM engine's own
508/// `scopeMatches` already splits on `.`; this aligns the Rust side. A `:`/`.` never appears WITHIN a
509/// real segment, so splitting on both never over-segments a Rust name (no spurious match).
510fn name_segments(s: &str) -> Vec<&str> {
511    s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
512}
513
514/// A policy scope matches a function name by **path segment** (SPEC §6.2), not substring: split both
515/// into segments (on `::` or `.`); the scope matches a contiguous run of name-segments where every
516/// segment except the last matches exactly and the last is a prefix. So `domain` matches
517/// `app::domain::h`, `com.acme.domain.h`, and `domain_logic` but not `subdomain`.
518pub fn scope_matches(name: &str, scope: &str) -> bool {
519    let segs = name_segments(name);
520    let parts = name_segments(scope);
521    if parts.is_empty() || parts.len() > segs.len() {
522        return false;
523    }
524    let (last, init) = parts.split_last().unwrap();
525    segs.windows(parts.len()).any(|w| {
526        let (w_last, w_init) = w.split_last().unwrap();
527        w_init == init && w_last.starts_with(last)
528    })
529}
530
531/// Reconstruct a rule's source form and the `Unknown`-forbidding upgrade for it: `pure <scope>` →
532/// (`"pure <scope>"`, `"deny Unknown <scope>"`); `deny <E…> <scope>` → (`"deny <E…> <scope>"`,
533/// `"deny <E…> Unknown <scope>"`). Shared so the gate note and `candor unverified` name the identical
534/// rule and upgrade — one source of truth for the disclosure's advice.
535///
536/// ⟨0.24⟩ **THE NARROWING FILTERS ARE RENDERED, and they had to start being rendered in the same commit
537/// that made them REACHABLE here.** Making `unverified_hole_rule` filter-aware is what first lets a
538/// `deny Unknown[reflect]` / `deny Net[unknown-host]` rule be the rule a hole is disclosed under — and
539/// this reconstruction dropped the bracket, so the fix would have printed the operator's narrowed rule
540/// back to them as the WIDE one (`deny Unknown`) and advised the nonsense upgrade `deny Unknown
541/// Unknown`. That is the same mis-attribution `whatif` carries, arriving through the fix for a different
542/// defect: the hazard on this rung is that each correction manufactures its own mirror, so the rendering
543/// moves with the predicate rather than after it.
544///
545/// A rule carrying NO filter renders byte-identically to before, which is what keeps conformance PARTs
546/// 12c/12d (`deny Db Net Unknown domain`, four-way) unmoved.
547///
548/// THE UPGRADE SPLITS on whether the rule already denies `Unknown`. If it does, it can only be here
549/// NARROWED — a bare `deny … Unknown` fires on every `Unknown`, so the function would be a violation and
550/// not a hole — and the upgrade is that term WIDENED to bare `Unknown`, not a second `Unknown` appended.
551pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
552    let scope = r.scope.clone().unwrap_or_default();
553    let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
554    if r.effects.is_empty() {
555        // `pure` forbids real effects but not Unknown; to REQUIRE provable purity, add a deny-Unknown.
556        return (format!("pure{suffix}"), format!("deny Unknown{suffix}"));
557    }
558    // One effect term, with its narrowing filter if it has one. Class tokens sorted by TOKEN string, as
559    // `parsepolicy` sorts them and as the java reference's `.sorted()` does — the dump and the
560    // disclosure must spell one rule one way.
561    let term = |e: &str| -> String {
562        if e == UNKNOWN && !r.unknown_classes.is_empty() {
563            let mut t: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
564            t.sort_unstable();
565            format!("{UNKNOWN}[{}]", t.join(","))
566        } else if e == "Net" && !r.net_classes.is_empty() {
567            format!("Net[{}]", r.net_classes.iter().map(String::as_str).collect::<Vec<_>>().join(","))
568        } else {
569            e.to_string()
570        }
571    };
572    let effs = r.effects.iter().map(|e| term(e)).collect::<Vec<_>>().join(" ");
573    if r.effects.contains(UNKNOWN) {
574        let widened =
575            r.effects.iter().map(|e| if *e == UNKNOWN { UNKNOWN.to_string() } else { term(e) }).collect::<Vec<_>>();
576        (format!("deny {effs}{suffix}"), format!("deny {}{suffix}", widened.join(" ")))
577    } else {
578        (format!("deny {effs}{suffix}"), format!("deny {effs} {UNKNOWN}{suffix}"))
579    }
580}
581
582/// The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
583/// `Unknown`, sits in a `pure`/`deny <E>` scope, and PASSES that rule (carries none of its forbidden real
584/// effects) — so its compliance is asserted but not verified (the Unknown could hide the very effect the
585/// rule forbids; the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not
586/// this. Returns the first governing rule under which the function is such a hole, or `None` if it is not
587/// one. Shared by candor-scan's gate note and candor-query's `unverified` so "what a hole is" has ONE
588/// definition — the two paths can never drift (conformance PART 12d pins their agreement).
589///
590/// ⟨0.24⟩ **"PASSES" IS NOW ASKED OF THE GATE, NOT OF A SECOND COPY OF IT.** This predicate computed the
591/// passing test from `r.effects` alone — "does the rule NAME an effect this function has?" — which is
592/// the pre-⟨0.19⟩ question, still being asked after two rungs gave rules a NARROWING FILTER. So on
593/// `deny Unknown[reflect]` over an `indirect` hole the gate TOLERATED (exit 0, the class does not match)
594/// while this read the same rule as violated; and a hole is *by definition* a function that PASSES its
595/// rule while `Unknown`, so the real hole was reclassified as a violation-that-isn't and **deleted from
596/// the disclosure** — `unverified` answering "every function in a pure/deny layer is PROVABLY clean ✓"
597/// over a function the gate had just declined to clear. MEASURED 2026-07-28, and reachable with no alias
598/// in play at all: one layer below the widening `ea0df4f` closed, in the same four verbs.
599///
600/// It now calls [`crate::gate::rule_hits`] — the gate's own firing decision — so the two cannot disagree
601/// again. That needs the function's TRANSITIVE reason classes and its ⟨0.20⟩ destination classes, the
602/// same two accumulators the gate reads, which is why they are parameters now rather than derivable.
603///
604/// **THE DIRECTION IS THE MIRROR ARGUMENT.** A filter can only ever SHRINK what a rule charges, so a
605/// filter-aware pass test can only ever find MORE holes — this cannot silently suppress a disclosure
606/// that used to appear. Pinned in both directions by
607/// `a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not`, in one run, because
608/// the fixture proving the fabrication is closed cannot show the reach closed with it. A WITHHELD filter
609/// — nothing to read — likewise counts as PASSING here: the gate did not clear the function, and an
610/// advisory note's fail-safe direction is to disclose.
611pub fn unverified_hole_rule<'a, S: AsRef<str>>(
612    name: &str,
613    effects: &[S],
614    reason_classes: Option<&BTreeSet<String>>,
615    net_classes: &[String],
616    rules: &'a [PolicyRule],
617) -> Option<&'a PolicyRule> {
618    if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
619        return None;
620    }
621    let effs: Vec<&str> = effects.iter().map(AsRef::as_ref).collect();
622    rules.iter().find(|r| {
623        // in the rule's scope (a scopeless rule governs the whole unit) …
624        if let Some(s) = &r.scope {
625            if !scope_matches(name, s) {
626                return false;
627            }
628        }
629        // … and PASSES it — the gate's own answer, narrowing filters and all. Empty `hits` IS passing.
630        crate::gate::rule_hits(r, &effs, reason_classes, net_classes).hits.is_empty()
631    })
632}
633
634/// Parse a CANDOR_POLICY file (SPEC §6.2). One rule per line; `#` comments and blanks ignored:
635///
636/// ```text
637/// deny Net Db  domain     # functions whose path contains segment "domain" must not perform Net or Db
638/// deny Exec               # no function anywhere may perform Exec
639/// deny Unknown  api        # functions in "api" must be fully resolvable (forbid the unverifiable)
640/// pure         parse      # functions whose path contains segment "parse" must be effect-free
641/// allow Net in billing  api.stripe.com
642/// forbid domain -> infra
643/// ```
644///
645/// In a `deny` rule, leading tokens that name a known effect (or `Unknown`) are forbidden; the FIRST
646/// non-effect token is the scope and ends the rule. A `deny` naming no known effect is dropped (it is
647/// NOT a `pure` rule). Malformed/unknown lines are ignored with a warning — never silently widened.
648/// The §6.2 token separator: ASCII whitespace ONLY (space/tab/CR/LF/VT/FF). `split_whitespace`/`trim`
649/// use Unicode `White_Space`, which would split a NBSP/ideographic space that Java drops — a gateless-
650/// green cross-engine divergence (adversarial DSL review). A non-ASCII space stays part of its token, so
651/// the rule is malformed and ignored, uniformly.
652fn is_ascii_ws(c: char) -> bool {
653    matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
654}
655
656pub fn parse_policy(text: &str) -> ParsedPolicy {
657    parse_policy_impl(text, true, &std::collections::BTreeMap::new())
658}
659/// As [`parse_policy`] but with `.candor/config` `unknown-alias` definitions (⟨0.19⟩, SPEC §6.2): an
660/// `Unknown[<name>]` filter resolves a user-defined `<name>` to its reason classes. The gate + `parsepolicy`
661/// pass the discovered aliases (via [`parse_unknown_aliases`]); a config alias never changes what bare
662/// `deny E Unknown` means (always `Unknown[*]`), so the rule stays legible from the policy alone.
663pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
664    parse_policy_impl(text, true, aliases)
665}
666/// ⟨0.24⟩ [`parse_policy_with_aliases`] but SILENT — for a caller that must inspect
667/// [`ParsedPolicy::errors`] / [`ParsedPolicy::used_aliases`] BEFORE it parses for real (candor-scan
668/// refuses before touching the classifier's accumulators). Silent so the ordinary parse warnings are not
669/// printed twice on the same text.
670pub fn parse_policy_silent(
671    text: &str,
672    aliases: &std::collections::BTreeMap<String, BTreeSet<ReasonClass>>,
673) -> ParsedPolicy {
674    parse_policy_impl(text, false, aliases)
675}
676/// Same as [`parse_policy`] but SILENT about malformed rules — for a SECOND, advisory re-parse within the
677/// same run (candor-scan parses once for the gate check and again for the `unverified` disclosure), so the
678/// CI log doesn't print every "ignoring policy rule …" warning twice (#21). The first parse already warned.
679pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
680    parse_policy_impl(text, false, &std::collections::BTreeMap::new())
681}
682fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
683    macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
684    let mut out = ParsedPolicy::default();
685    // ⟨0.24⟩ Record a line the parser did not honour (SPEC §3.1 `195d45a`), on the ONE list `parsepolicy`
686    // reports and the gate routes filter for `fatal`. The stderr sentence and `message` are the SAME
687    // string by construction — a disclosure that can drift from the one beside it is how this family
688    // produced a FALSE disclosure once already (conformance PART 13b).
689    macro_rules! not_honoured {
690        ($fatal:expr, $kind:expr, $token:expr, $accepted:expr, $rule:expr, $msg:expr) => {{
691            let message: String = $msg;
692            out.errors.push(PolicyError {
693                kind: $kind,
694                token: ($token).to_string(),
695                accepted: ($accepted).iter().map(|s: &&str| s.to_string()).collect(),
696                rule: ($rule).to_string(),
697                message,
698                fatal: $fatal,
699            });
700        }};
701    }
702    // `str::lines()` splits on \n and \r\n but NOT bare \r — a classic-Mac file then collapses to ONE
703    // line, and since \r is also an in-line ASCII-ws token separator (is_ascii_ws), every rule after the
704    // first was glued into the first rule's tokens and dropped (sweep [16], a gateless-green divergence).
705    // Java's Files.readAllLines (the reference) breaks on bare \r too — normalize to match it. Allocation
706    // only when a bare \r is actually present (the overwhelmingly-common \n / \r\n files are untouched).
707    let normalized;
708    let text = if text.contains('\r') {
709        normalized = text.replace("\r\n", "\n").replace('\r', "\n");
710        normalized.as_str()
711    } else {
712        text
713    };
714    for raw_line in text.lines() {
715        let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
716        if line.is_empty() {
717            continue;
718        }
719        let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
720        match toks.next().unwrap_or("") {
721            "allow" => {
722                let effect = match toks.next().unwrap_or("") {
723                    "Net" => "Net",
724                    // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — `allow Llm <host…>` restricts which
725                    // MODEL hosts a scope may reach, matched by hostname like Net (its reached surface IS the
726                    // Net host surface). Match candor-java's Policy.parsePolicy.
727                    "Llm" => "Llm",
728                    "Exec" => "Exec",
729                    "Fs" => "Fs",
730                    "Db" => "Db",
731                    other => {
732                        let msg = format!(
733                            "unknown effect-name `{other}` in `allow` (accepted: Db, Exec, Fs, Llm, Net \
734                             \u{2014} `allow` covers only the effects carrying a literal surface: Net/Llm \
735                             hosts, Exec commands, Fs paths, Db tables): {line}"
736                        );
737                        warn_ignore!("candor: policy error — {msg}");
738                        // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way before this:
739                        // `allow Nett host.example` -> exit 0 on rust, ts, java AND swift. The rule is
740                        // DELETED and the certification silently vanishes, so the operator reads an
741                        // armed allowlist that does not exist.
742                        //
743                        // The grammar defence that kept the token rule inside the bracket does NOT
744                        // reach here: `allow`'s effect position is a fixed, closed set with **no scope
745                        // reading available**, so an unrecognised token there is unambiguously a typo
746                        // and there is no legitimate policy it could be. This document already calls a
747                        // dropped rule "the limit case of silently rewritten into a different policy…
748                        // a bigger rewrite than a narrowed filter" — and the bigger rewrite was
749                        // warning-only while the smaller one was already exit 2.
750                        not_honoured!(
751                            true,
752                            PolicyError::KIND_EFFECT_NAME,
753                            other,
754                            ["Db", "Exec", "Fs", "Llm", "Net"],
755                            line,
756                            msg
757                        );
758                        continue;
759                    }
760                };
761                let mut rest: Vec<&str> = toks.collect();
762                let scope = if rest.first() == Some(&"in") {
763                    let s = rest.get(1).map(|s| s.to_string());
764                    rest.drain(..2.min(rest.len()));
765                    s
766                } else {
767                    None
768                };
769                let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
770                if literals.is_empty() {
771                    let msg = format!("`allow {effect}` names no values: {line}");
772                    warn_ignore!("candor: ignoring policy rule ({msg})");
773                    // `accepted` is EMPTY on purpose: the position takes an open-ended literal (a host, a
774                    // path, a command, a table), so there is no token list to offer. A fact about the
775                    // grammar, not a gap in the report.
776                    not_honoured!(false, PolicyError::KIND_RULE_KIND, "", [], line, msg);
777                    continue;
778                }
779                out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
780            }
781            "deny" => {
782                let mut effects = BTreeSet::new();
783                let mut scope = None;
784                // Reason-class filter on `Unknown` (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒ `Unknown[*]`
785                // (any reason — the bare form); non-empty ⇒ only those classes. `*` = all.
786                let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
787                let mut unknown_star = false;
788                // Destination-class filter on `Net` (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
789                // (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
790                let mut net_classes: BTreeSet<String> = BTreeSet::new();
791                let mut net_star = false;
792                for t in toks {
793                    // `Net[unknown-host]` / `Net[*]` / `Net[known-telemetry,unknown-host]`: the destination-scoped form.
794                    if let Some(inner) = t.strip_prefix("Net[").and_then(|s| s.strip_suffix(']')) {
795                        effects.insert("Net");
796                        for cn in inner.split(',') {
797                            let cn = cn.trim();
798                            if cn.is_empty() {
799                                continue;
800                            }
801                            if cn == "*" {
802                                net_star = true;
803                            } else if crate::NET_DEST_CLASSES.contains(&cn) {
804                                net_classes.insert(cn.to_string());
805                            } else {
806                                // ⟨0.24⟩ A POLICY ERROR, not a warning — SPEC §6.2 `be0b9a9`. Byte-identical
807                                // in shape to the reason-class arm below, and byte-identical in harm:
808                                // MEASURED `deny Net[known-telemetry,unknown-hosst]` → exit 0 where the
809                                // correctly-spelled rule exits 1. The typo is dropped, the filter NARROWS
810                                // to `[known-telemetry]`, and the gate stops covering unidentifiable
811                                // destinations while the operator reads a gate that looks armed.
812                                not_honoured!(
813                                    true,
814                                    PolicyError::KIND_NET_CLASS,
815                                    cn,
816                                    ["known-telemetry", "known-partner", "unknown-host", "*"],
817                                    line,
818                                    format!(
819                                        "unrecognised Net destination-class `{cn}` in `{line}` — \
820                                         accepted: known-telemetry, known-partner, unknown-host, plus `*`"
821                                    )
822                                );
823                            }
824                        }
825                        continue;
826                    }
827                    // `Unknown[dispatch,reflect]` / `Unknown[*]` / `Unknown[dynamic]`: the reason-scoped form.
828                    if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
829                        effects.insert(UNKNOWN);
830                        for cn in inner.split(',') {
831                            let cn = cn.trim();
832                            if cn.is_empty() {
833                                continue;
834                            }
835                            if cn == "*" {
836                                unknown_star = true;
837                            } else if cn == "dynamic" {
838                                unknown_classes.extend(ReasonClass::dynamic_set());
839                            } else if let Some(rc) = ReasonClass::from_token(cn) {
840                                unknown_classes.insert(rc);
841                            } else if let Some(a) = aliases.get(cn) {
842                                unknown_classes.extend(a.iter().copied()); // ⟨0.19⟩ config `unknown-alias`
843                                // ⟨0.24⟩ → the verdict names it AND what it expanded to (SPEC §3.1
844                                // `7f5b5ba`): the NAME alone cannot tell a reader which gate ran.
845                                out.used_aliases
846                                    .insert(cn.to_string(), a.iter().map(|c| c.token().to_string()).collect());
847                            } else {
848                                // ⟨0.24⟩ A POLICY ERROR, not a warning — see `ParsedPolicy::errors`. The
849                                // token is still dropped below so `rules` stays well-formed for the
850                                // advisory readers (`unverified`, `parsepolicy`); the gate routes refuse
851                                // on `errors` before any of it is used as a verdict.
852                                not_honoured!(
853                                    true,
854                                    PolicyError::KIND_REASON_CLASS,
855                                    cn,
856                                    [
857                                        "reflect",
858                                        "dispatch",
859                                        "indirect",
860                                        "native",
861                                        "unresolved",
862                                        "setup",
863                                        "dynamic",
864                                        "*"
865                                    ],
866                                    line,
867                                    format!(
868                                        "unrecognised reason-class/alias `{cn}` in `{line}` — accepted: \
869                                         reflect, dispatch, indirect, native, unresolved, setup, plus the \
870                                         aliases `dynamic` and `*`, plus any `unknown-alias` defined in \
871                                         the `.candor/config` beside the policy. (⟨0.24⟩ an \
872                                         `unknown-alias` whose OWN definition names an unrecognised class \
873                                         is refused WHOLE, so a typo in the config surfaces as an \
874                                         undefined alias here — check the `unknown-alias` lines too, and \
875                                         the line above this one.)"
876                                    )
877                                );
878                            }
879                        }
880                        continue;
881                    }
882                    let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
883                    match e {
884                        Some(e) => {
885                            effects.insert(e);
886                            if e == UNKNOWN {
887                                unknown_star = true; // bare Unknown ⇒ all classes
888                            }
889                            if e == "Net" {
890                                net_star = true; // bare Net ⇒ all destinations
891                            }
892                        }
893                        None => {
894                            scope = Some(t.to_string());
895                            break;
896                        }
897                    }
898                }
899                if effects.is_empty() {
900                    // The accepted set is the §1 effect vocabulary plus `Unknown` — SORTED, so the
901                    // document is deterministic and diffable across engines.
902                    let mut acc: Vec<&str> = candor_report::EFFECTS.to_vec();
903                    acc.push(UNKNOWN);
904                    acc.sort_unstable();
905                    let msg = format!(
906                        "`deny` names no known effect (accepted: {}): {line}",
907                        acc.join(", ")
908                    );
909                    warn_ignore!("candor: policy error — {msg}");
910                    // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way: `deny Nett app` -> exit 0
911                    // on all four; the rule is DELETED and the gate is green. `Nett` is read as the
912                    // SCOPE (the first unrecognised token ends the effect list), so the line parses to
913                    // a deny of NOTHING.
914                    //
915                    // **A `deny` whose effect list ends up EMPTY is malformed under EITHER reading** —
916                    // typo-in-the-effect or scope-with-no-effect are both nonsense — so there is no
917                    // legitimate policy it could be and refusing it loses nothing. What stays open is
918                    // only the genuinely ambiguous middle (`deny Net Exex app`: at least one valid
919                    // effect plus an unrecognised trailing token that MIGHT be a scope), which the
920                    // parser cannot tell from a legitimate scope and which `parsepolicy` shows either
921                    // way by dumping the `scope` it read.
922                    not_honoured!(
923                        true,
924                        PolicyError::KIND_EFFECT_NAME,
925                        scope.as_deref().unwrap_or(""),
926                        acc,
927                        line,
928                        msg
929                    );
930                    continue;
931                }
932                // `*` (or bare `Unknown`) means all classes ⇒ empty filter (matches any Unknown).
933                if unknown_star {
934                    unknown_classes.clear();
935                } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
936                    // A2 under-gating lint: a narrowed scope that omits `unresolved` (the catch-all for holes
937                    // an engine couldn't classify) may silently tolerate exactly those — flag it (advisory).
938                    warn_ignore!("candor: policy rule narrows `Unknown[…]` but omits `unresolved` — may UNDER-gate on holes the engine couldn't classify; add `unresolved` (or use `dynamic`) to stay conservative: {line}");
939                }
940                // `*` (or bare `Net`) means all destinations ⇒ empty filter (matches any Net).
941                if net_star {
942                    net_classes.clear();
943                }
944                out.rules.push(PolicyRule { effects, scope, unknown_classes, net_classes, raw: line.to_string() });
945            }
946            "pure" => out.rules.push(PolicyRule {
947                effects: BTreeSet::new(),
948                scope: toks.next().map(str::to_string),
949                unknown_classes: BTreeSet::new(),
950                net_classes: BTreeSet::new(),
951                raw: line.to_string(),
952            }),
953            "forbid" => {
954                let a = toks.next().unwrap_or("");
955                let arrow = toks.next().unwrap_or("");
956                let b = toks.next().unwrap_or("");
957                if a.is_empty() || arrow != "->" || b.is_empty() {
958                    let msg = format!("`forbid` is malformed (want `forbid <scope> -> <scope>`): {line}");
959                    warn_ignore!("candor: ignoring layering rule ({msg})");
960                    // The token reported is whatever sat in the ARROW position — `->` must be its own
961                    // token, so `forbid glued->arrow` finds nothing there and that absence is the finding.
962                    not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
963                    continue;
964                }
965                out.layer_rules.push(LayerRule {
966                    from: a.to_string(),
967                    to: b.to_string(),
968                    raw: line.to_string(),
969                });
970            }
971            other => {
972                let msg = format!(
973                    "unknown rule kind `{other}` (accepted: allow, deny, forbid, pure): {line}"
974                );
975                warn_ignore!("candor: ignoring policy rule ({msg})");
976                not_honoured!(
977                    false,
978                    PolicyError::KIND_RULE_KIND,
979                    other,
980                    ["allow", "deny", "forbid", "pure"],
981                    line,
982                    msg
983                );
984            }
985        }
986    }
987    out
988}
989
990#[cfg(test)]
991mod tests {
992    #[test]
993    fn db_table_covering_is_strict() {
994        use super::db_table_covered as c;
995        assert!(c("ledger.entries", "Ledger.Entries")); // case-insensitive exact
996        assert!(c("ledger.*", "ledger.entries"));       // schema wildcard
997        assert!(!c("ledger.*", "ledgerx.entries"));     // boundary-respecting
998        assert!(!c("entries", "ledger.entries"));       // no silent qualification widening
999        assert!(c("entries", "entries"));
1000    }
1001
1002    #[test]
1003    fn allow_db_parses_and_gates() {
1004        let p = super::parse_policy("allow Db in billing  ledger.* customers\n");
1005        assert_eq!(p.allow_rules.len(), 1);
1006        assert_eq!(p.allow_rules[0].effect, "Db");
1007        assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
1008        assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
1009        assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
1010    }
1011
1012    use super::*;
1013
1014    #[test]
1015    fn policy_parses() {
1016        let p = parse_policy(
1017            "# the domain layer must stay pure of I/O\n\
1018             deny Net Db  domain\n\
1019             deny Exec\n\
1020             pure  parse\n\
1021             nonsense line\n\
1022             deny notaneffect\n",
1023        );
1024        let rules = &p.rules;
1025        assert_eq!(rules.len(), 3);
1026        assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
1027        assert_eq!(rules[0].scope.as_deref(), Some("domain"));
1028        assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
1029        assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
1030        // sweep [16]: a classic-Mac (bare \r) multi-rule policy must NOT collapse to the first rule.
1031        let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
1032        assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
1033        assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
1034        // mixed \r\n and bare \r normalize identically.
1035        assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
1036        // `Unknown` is a denyable token; a bare `deny` with no effect is ignored.
1037        assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
1038        assert!(parse_policy("deny\ndeny   \n").rules.is_empty());
1039        // a `deny` whose first token is a non-effect names no effect -> dropped, NOT a pure rule.
1040        assert!(parse_policy("deny notaneffect scope").rules.is_empty());
1041        // the first non-effect token ENDS the rule: a later effect token is not collected.
1042        let p2 = parse_policy("deny Net foo Db");
1043        assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1044        assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
1045        // NBSP is NOT a token separator (only ASCII White_Space is) — pinned to MATCH Java, which
1046        // drops it: a `deny\u{a0}Net` is one token `deny\u{a0}Net`, NOT `deny` + `Net`, so it names no
1047        // known effect and is dropped. Splitting on Unicode whitespace here would let candor see a deny
1048        // the JVM engine doesn't — a gateless-divergence between impls. (See is_ascii_ws.)
1049        assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
1050                "an NBSP between deny and the effect must NOT split into separate tokens");
1051        // The NBSP rides INTO the scope token rather than separating it: `deny Net\u{a0}domain` is
1052        // `deny` + `Net` + `\u{a0}domain` — Net is the effect, the scope keeps the NBSP verbatim.
1053        let nb = parse_policy("deny Net \u{a0}domain");
1054        assert_eq!(nb.rules.len(), 1);
1055        assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1056        assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
1057    }
1058
1059    #[test]
1060    fn reason_scoped_unknown_parses() {
1061        use super::ReasonClass::*;
1062        // `Unknown[dispatch,indirect]` narrows the Unknown membership to those classes.
1063        let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
1064        let r = &p.rules[0];
1065        assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
1066        assert_eq!(r.scope.as_deref(), Some("dom"));
1067        assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
1068        // bare `Unknown` and `Unknown[*]` ⇒ empty filter (all classes).
1069        assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
1070        assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
1071        // `dynamic` alias = every genuine class incl. unresolved, excl. setup.
1072        assert_eq!(
1073            parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
1074            [Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
1075        );
1076        // config `unknown-alias` (⟨0.19⟩): a user-defined name resolves; a reserved name is rejected.
1077        let aliases = super::parse_unknown_aliases(
1078            "unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
1079        assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
1080        assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
1081        assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
1082        // the `unknown-alias` KEY matches case-insensitively (parity with java/ts/swift, which lowercase it)
1083        assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
1084                   Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
1085        let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
1086        assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
1087        // an UNDEFINED alias name is dropped-with-warning → empty filter (behaves like bare Unknown[*])
1088        assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
1089        // classify: raw reason tokens → normative classes (mirrors java ReasonClass.classify).
1090        assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
1091        assert_eq!(ReasonClass::classify("native:extern fn"), Native);
1092        assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
1093        assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
1094        assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
1095        assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); // conservative catch-all
1096    }
1097
1098    /// THE CONTROL SPEC §4 ⟨0.24⟩ MAKES A SHOULD: a FABRICATED, off-vocabulary kind must still behave as
1099    /// §2 forward-compatibility requires. Without it, "added a fifth kind" and "stopped checking the kind
1100    /// set" are the same diff — the classifier is one `_ =>` arm away from either.
1101    ///
1102    /// This engine holds the §4 vocabulary ONCE (the raw `kind:detail` string, read back only through
1103    /// `classify`), so there is no typed half here to drift from it. That is why the JVM engine's failure
1104    /// — a string classifier correct on `ambiguous` since July while its typed `Kind` enum lacked the kind
1105    /// entirely, one token classified two ways inside one engine — is not reproducible here. If a typed
1106    /// kind representation is ever added, this test is where its half gets its control.
1107    #[test]
1108    fn off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all() {
1109        use ReasonClass::*;
1110        // A kind no engine emits and no section names. §2: tolerated, and classified CONSERVATIVELY —
1111        // `unresolved`, the catch-all, so a narrowed `Unknown[unresolved]`/`[dynamic]`/`[*]` still bites it.
1112        assert_eq!(ReasonClass::classify("banana:whatever"), Unresolved);
1113        assert_eq!(ReasonClass::classify("banana:dispatch of a banana"), Unresolved,
1114                   "a canonical kind appearing in the DETAIL must not leak into the classification");
1115        // …and it must not be swallowed into a narrower class. These are the four wrong answers.
1116        for wrong in [Reflect, Dispatch, Indirect, Native] {
1117            assert_ne!(ReasonClass::classify("banana:whatever"), wrong);
1118        }
1119        // The five §4 ⟨0.24⟩ kinds all classify, and `ambiguous` is the fifth — pinned beside the
1120        // fabricated one deliberately: one arm chain answers both, so a change that stops distinguishing
1121        // them fails here rather than in the field.
1122        assert_eq!(ReasonClass::classify("reflect:x"), Reflect);
1123        assert_eq!(ReasonClass::classify("native:x"), Native);
1124        assert_eq!(ReasonClass::classify("dispatch:Owner.member"), Dispatch);
1125        assert_eq!(ReasonClass::classify("callback:x"), Indirect);
1126        assert_eq!(ReasonClass::classify("ambiguous:x"), Dispatch);
1127        // ⟨0.24⟩ `dep:<hash>` / `dep-stale:<pkg>` are REGISTERED §4 kinds, not migration ones — swift
1128        // emits them per dependency ENTRY, and this engine CONSUMES swift/ts reports through
1129        // candor-query. §6.2 pins their class as `unresolved`, which is where the catch-all lands them;
1130        // pinned so a future prefix arm cannot move them without saying so.
1131        assert_eq!(ReasonClass::classify("dep:9f2c1a"), Unresolved);
1132        assert_eq!(ReasonClass::classify("dep-stale:somepkg"), Unresolved);
1133    }
1134
1135    #[test]
1136    fn net_destination_class_parses_and_classifies() {
1137        // `Net[unknown-host,known-telemetry]` narrows the Net membership to those destination classes.
1138        let p = parse_policy("deny Net[unknown-host,known-telemetry] dom\n");
1139        let r = &p.rules[0];
1140        assert!(r.effects.contains("Net"));
1141        assert_eq!(r.scope.as_deref(), Some("dom"));
1142        assert_eq!(
1143            r.net_classes,
1144            ["unknown-host", "known-telemetry"].iter().map(|s| s.to_string()).collect()
1145        );
1146        // bare `Net` and `Net[*]` ⇒ empty filter (all destinations).
1147        assert!(parse_policy("deny Net dom\n").rules[0].net_classes.is_empty(), "bare Net ⇒ all");
1148        assert!(parse_policy("deny Net[*] dom\n").rules[0].net_classes.is_empty(), "Net[*] ⇒ all");
1149        // an unknown destination-class is dropped-with-warning → empty filter (behaves like bare Net[*]).
1150        assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
1151        // the classifier: telemetry (subdomain-aware), model host, unresolved, and the config-partner path.
1152        let no_partners = BTreeSet::new();
1153        assert_eq!(crate::net_dest_class("sentry.io", &no_partners), "known-telemetry");
1154        assert_eq!(crate::net_dest_class("us.i.posthog.com", &no_partners), "known-telemetry"); // 0.20.1 corpus-grown
1155        assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
1156        assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
1157        assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
1158        let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
1159        assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
1160        assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
1161        // `net-partner` config parsing: host-normalized, case-insensitive key, multi-value.
1162        let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
1163        assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
1164    }
1165
1166    #[test]
1167    fn allowlist_parses() {
1168        let p = parse_policy(
1169            "allow Net in billing  api.stripe.com  hooks.stripe.com\n\
1170             allow Exec in ci  git\n\
1171             allow Fs in config  /etc/app\n\
1172             allow Net  github.com\n\
1173             allow Clock  whatever\n\
1174             allow Net in nohosts\n\
1175             allow\n",
1176        );
1177        assert_eq!(p.allow_rules.len(), 4); // Clock carries no literal surface — rejected; Db now does
1178        assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
1179        assert_eq!(
1180            p.allow_rules[0].literals,
1181            ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
1182        );
1183        assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
1184        assert!(p.allow_rules[1].literals.contains("git"));
1185        assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
1186        assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
1187
1188        let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
1189        assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
1190        // IPv6: a bare literal is matched WHOLE (no first-colon collapse), so a different address in the
1191        // same block is NOT accepted; a bracketed `[host]:port` matches the bare host. (/code-review.)
1192        assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
1193        assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
1194        assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
1195        assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
1196        assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
1197        assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
1198        assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
1199        assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
1200        assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
1201        assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
1202        assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
1203        assert_eq!(cmd_base("/usr/bin/git"), "git");
1204    }
1205
1206    #[test]
1207    fn layering_rule_parses() {
1208        let p = parse_policy(
1209            "forbid domain -> infra\n\
1210             forbid  app::web  ->  app::db \n\
1211             forbid domain infra\n\
1212             forbid domain ->\n\
1213             forbid\n",
1214        );
1215        assert_eq!(p.layer_rules.len(), 2);
1216        assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
1217        assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
1218    }
1219
1220    #[test]
1221    fn scope_matches_by_segment_not_substring() {
1222        assert!(scope_matches("app::domain::handle", "domain"));
1223        assert!(scope_matches("domain::handle", "domain"));
1224        assert!(scope_matches("app::domain", "domain"));
1225        assert!(scope_matches("crate::domain_logic", "domain"));
1226        assert!(!scope_matches("app::subdomain::handle", "domain"));
1227        assert!(!scope_matches("app::not_my_domain::f", "domain"));
1228        // multi-segment: intermediates exact, last is a prefix, contiguous.
1229        assert!(scope_matches("crate::net::client::send", "net::client"));
1230        assert!(scope_matches("crate::net::client_pool::get", "net::client"));
1231        assert!(!scope_matches("crate::net::server::send", "net::client"));
1232        assert!(!scope_matches("crate::network::client::send", "net::client"));
1233        assert!(!scope_matches("crate::net::x::client", "net::client"));
1234        assert!(!scope_matches("net", "net::client"));
1235        // DOTTED names (JVM/Swift/TS reports `candor-query` consumes): a scope must match across `.` too,
1236        // else a scoped deny/pure rule is silently inert → whatif false-green (gate-evasion). Both a
1237        // `.`-written and a `::`-written scope must match a dotted name.
1238        assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
1239        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
1240        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
1241        assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
1242        assert!(!scope_matches("com.acme.subdomain.h", "domain"));
1243        assert!(!scope_matches("com.acme.domain.h", "infra"));
1244    }
1245
1246    #[test]
1247    fn fs_path_covered_respects_boundaries() {
1248        assert!(fs_path_covered("/etc/app", "/etc/app"));
1249        assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
1250        assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
1251        assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
1252        assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
1253        assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
1254        assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
1255        assert!(fs_path_covered("/", "/etc/app/x"));
1256        assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
1257        assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
1258        assert!(fs_path_covered("etc/app", "etc/app/cfg"));
1259    }
1260
1261    /// ⟨0.24⟩ THE PROVABLE-PURITY DISCLOSURE MUST ASK THE GATE WHAT "PASSES" MEANS — SPEC §6.2, and both
1262    /// directions in ONE test, because killing an over-charge is exactly where a silent under-report gets
1263    /// introduced and the fixture proving the fabrication is closed cannot show the reach closed with it.
1264    ///
1265    /// A hole is a function that PASSES its rule while `Unknown`. `unverified_hole_rule` used to compute
1266    /// PASSES from `r.effects` alone — the pre-⟨0.19⟩ question, asked after two rungs gave rules a
1267    /// NARROWING FILTER — so a rule the gate TOLERATES was read here as violated and the hole was DELETED
1268    /// from the disclosure. MEASURED 2026-07-28: `deny Unknown[reflect]` over an `indirect` hole → gate
1269    /// exit 0, `unverified` "every function in a pure/deny layer is PROVABLY clean ✓".
1270    ///
1271    /// ROW 1 (the fix) — the filter does NOT match, so the gate tolerates and this IS a hole.
1272    /// ROW 2 (the mirror) — the SAME rule, SAME function, filter spelled to MATCH: the gate fires, so it
1273    /// is a violation and NOT a hole. Without row 2 the fix is satisfied by a predicate that calls
1274    /// everything a hole, which is the mirror over-report of the thing being fixed.
1275    /// ROW 3 — the ⟨0.20⟩ `Net[dest…]` filter, the same shape on the other narrowing axis.
1276    /// ROW 4 — no filter at all: byte-identical to pre-⟨0.24⟩, which is what keeps conformance PARTs
1277    /// 12c/12d (four-way) from moving.
1278    #[test]
1279    fn a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not() {
1280        let effs = ["Unknown"];
1281        let indirect: BTreeSet<String> = ["indirect".to_string()].into_iter().collect();
1282        let hole = |src: &str, classes: Option<&BTreeSet<String>>, nets: &[String]| -> Option<String> {
1283            let p = parse_policy(src);
1284            unverified_hole_rule("app::go", &effs, classes, nets, &p.rules).map(|r| rule_and_upgrade(r).1)
1285        };
1286
1287        // ROW 1 — tolerated by the gate (indirect ∉ {reflect}) ⇒ a hole, and the upgrade WIDENS the
1288        // filter rather than appending a second `Unknown`.
1289        assert_eq!(
1290            hole("deny Unknown[reflect]\n", Some(&indirect), &[]).as_deref(),
1291            Some("deny Unknown"),
1292            "a rule the gate TOLERATES leaves the function unproven — that is the disclosure's whole subject"
1293        );
1294
1295        // ROW 2 — THE MIRROR. Same rule, same signature, filter spelled to match: the gate FIRES, so this
1296        // is a violation and the disclosure must stay silent about it.
1297        assert_eq!(
1298            hole("deny Unknown[indirect]\n", Some(&indirect), &[]),
1299            None,
1300            "a rule the gate FIRES on is a violation, not a hole — filter-awareness must not start \
1301             disclosing the gate's own findings back as unproven passes"
1302        );
1303        assert_eq!(hole("deny Unknown[dynamic]\n", Some(&indirect), &[]), None, "`dynamic` covers indirect");
1304
1305        // ROW 3 — the ⟨0.20⟩ destination filter, both ways, on a fn carrying Net BESIDE its Unknown.
1306        let netfn = ["Net".to_string(), "Unknown".to_string()];
1307        let telemetry = vec!["known-telemetry".to_string()];
1308        let p = parse_policy("deny Net[unknown-host]\n");
1309        assert_eq!(
1310            unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules)
1311                .map(|r| rule_and_upgrade(r).1)
1312                .as_deref(),
1313            Some("deny Net[unknown-host] Unknown"),
1314            "a `Net[dest…]` the fn's destinations do not match is tolerated, so the Unknown beside it is a hole"
1315        );
1316        let p = parse_policy("deny Net[known-telemetry]\n");
1317        assert_eq!(
1318            unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules).map(|r| r.raw.clone()),
1319            None,
1320            "MIRROR: the matching destination filter FIRES, so it is a violation and not a hole"
1321        );
1322
1323        // ROW 4 — UNFILTERED, unchanged. `deny Unknown` fires on every Unknown (never a hole); `pure` and
1324        // `deny Fs` pass a fn with no real effect (always a hole) — the forms PARTs 12c/12d pin four-way.
1325        assert_eq!(hole("deny Unknown\n", Some(&indirect), &[]), None);
1326        assert_eq!(hole("pure\n", Some(&indirect), &[]).as_deref(), Some("deny Unknown"));
1327        assert_eq!(hole("deny Net Db  domain\ndeny Fs\n", Some(&indirect), &[]).as_deref(), Some("deny Fs Unknown"));
1328        assert_eq!(
1329            hole("deny Net Db  go\n", Some(&indirect), &[]).as_deref(),
1330            Some("deny Db Net Unknown go"),
1331            "the sorted multi-effect upgrade PART 12c-deny pins in all four engines"
1332        );
1333
1334        // A WITHHELD filter — no class set to read — counts as PASSING, so the hole is disclosed rather
1335        // than dropped. The gate withholds there too; between an advisory note that speaks and one that
1336        // goes quiet over a rule that never ran, only the first stays true.
1337        assert_eq!(hole("deny Unknown[reflect]\n", None, &[]).as_deref(), Some("deny Unknown"));
1338    }
1339}