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/// SPEC §4 ⟨0.7⟩'s CLOSED FIVE-KIND `unknownWhy` VOCABULARY, as a TABLE — and the single place this
18/// engine SPELLS a reason, not merely the single place it reads one.
19///
20/// SOUNDNESS R490. `ReasonClass::classify` below has carried the note "THIS IS THE ONLY PLACE THIS
21/// ENGINE HOLDS SPEC §4's KIND VOCABULARY" since ⟨0.24⟩, and it was true of the READER and false of the
22/// WRITERS: the nightly dylint lint at the repo root is a SECOND PRODUCER publishing into
23/// `.candor/baseline.candor.Cdylib.json`, and it had drifted in three kinds (`generic-iter:`,
24/// `iter-combinator:`, `deref:`) and two details. That is the hazard `classify`'s own comment describes,
25/// one repo over and in the direction it was not looking.
26///
27/// SO THIS TABLE IS WHAT BOTH HALVES READ. `classify` matches on `Kind::token()` rather than on its own
28/// copy of the five strings, so adding, renaming or removing a kind moves the classifier and the
29/// producers in the same edit — the JVM engine's failure (a correct `classify` beside a typed `Kind`
30/// enum missing `ambiguous`, one token with two answers inside one engine) is not reachable from here.
31///
32/// It is NOT a closed set for PARSING. §2 forward-compatibility lets a producer emit a kind this
33/// version has never heard of, and `classify`'s catch-all is what keeps such a kind inside
34/// `Unknown[*]` / `Unknown[dynamic]`. This type constrains what THIS ENGINE WRITES.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum Kind {
37    /// invocation chosen at runtime by name/metadata — reflection, `Method.invoke`, `eval`
38    Reflect,
39    /// a boundary to code the engine cannot analyse — native methods, FFI/`extern`, intrinsics
40    Native,
41    /// an unresolved virtual / interface / protocol dispatch with a resolvable owner type + member
42    Dispatch,
43    /// an unresolved higher-order / owner-less invocation over a function/closure VALUE — §4 names the
44    /// shapes: param, field, bound, computed, opaque-iterable
45    Callback,
46    /// ⟨0.24⟩ the analyser's OWN name resolution was ambiguous — two or more separately-written
47    /// definitions competing for one name, so no owner could be formed at all
48    Ambiguous,
49}
50
51impl Kind {
52    /// The §4 `kind` token — the part before the first `:`, and the part every gate reads.
53    pub fn token(self) -> &'static str {
54        match self {
55            Kind::Reflect => "reflect",
56            Kind::Native => "native",
57            Kind::Dispatch => "dispatch",
58            Kind::Callback => "callback",
59            Kind::Ambiguous => "ambiguous",
60        }
61    }
62
63    /// The five, in a form a census can iterate. `classify` and the source-hygiene census both read
64    /// THIS, so neither can hold its own list.
65    pub const ALL: [Kind; 5] = [Kind::Reflect, Kind::Native, Kind::Dispatch, Kind::Callback, Kind::Ambiguous];
66
67    /// SPEC §6.2's projection of this kind onto a policy CLASS. `ambiguous` projects to `dispatch`:
68    /// §6.2's table has named `ambiguous*` since ⟨0.19⟩, and reclassifying it to `indirect` was measured
69    /// to take `deny E Unknown[dispatch]` from 58 of 200 crates to 0 of 200.
70    pub fn class(self) -> ReasonClass {
71        match self {
72            Kind::Reflect => ReasonClass::Reflect,
73            Kind::Native => ReasonClass::Native,
74            Kind::Dispatch | Kind::Ambiguous => ReasonClass::Dispatch,
75            Kind::Callback => ReasonClass::Indirect,
76        }
77    }
78
79    /// Spell a §4 reason: `kind:detail`. The detail is best-effort for every kind EXCEPT `dispatch:`
80    /// with an owner — use [`Kind::dispatch_on`] there, which is the normative shape.
81    ///
82    /// ⟨0.24⟩ A DOT-FREE `dispatch:` detail is not a defect: it is the RESERVED form for "no owner could
83    /// be formed at all", free text, explicitly not conformance-compared. That is why this constructor
84    /// accepts one rather than forcing every caller through `dispatch_on`.
85    pub fn reason(self, detail: &str) -> String {
86        format!("{}:{}", self.token(), detail)
87    }
88
89    /// `dispatch:<owner-type>.<member>` — §4's ONE normative detail, and the shape §3.1's dispatch
90    /// frontier parses (`possibleViaUnknownDispatch` keys on the dot). The owner is passed as the type's
91    /// own name, never as a language path: `Store.put`, not `p::Store::put`.
92    pub fn dispatch_on(owner: &str, member: &str) -> String {
93        format!("{}:{owner}.{member}", Kind::Dispatch.token())
94    }
95}
96
97/// The NORMATIVE projection of a raw `unknown_why` reason onto a fixed, cross-engine reason CLASS
98/// (candor-spec REASON-SCOPED-UNKNOWN-DESIGN.md §1). Reason-scoped policies (`deny E Unknown[class]`)
99/// quantify over these classes, so the mapping MUST be identical in every engine — this mirrors the
100/// java reference `ReasonClass` (its `classify(String)` path, since rust emits raw string reasons). The
101/// class set is CLOSED (six members); a raw reason matching no pinned prefix maps to `Unresolved` —
102/// conservative: it stays in scope of any `Unknown[*]` / `Unknown[dynamic]` policy, never silently
103/// tolerated.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
105pub enum ReasonClass {
106    /// reflection / metaprogramming
107    Reflect,
108    /// unresolved virtual/dynamic dispatch, same-name ambiguity, invokedynamic
109    Dispatch,
110    /// callback / closure / function-value / async-continuation indirection
111    Indirect,
112    /// FFI / native boundary
113    Native,
114    /// generic unresolvable call/import, AND the catch-all for any unrecognized raw reason
115    Unresolved,
116    /// analysis not wired up (fixable, not a real dynamic hole): missing-config / no-tsconfig
117    Setup,
118}
119
120impl ReasonClass {
121    /// The lowercase policy-facing token (`deny E Unknown[<token>]`).
122    pub fn token(self) -> &'static str {
123        match self {
124            ReasonClass::Reflect => "reflect",
125            ReasonClass::Dispatch => "dispatch",
126            ReasonClass::Indirect => "indirect",
127            ReasonClass::Native => "native",
128            ReasonClass::Unresolved => "unresolved",
129            ReasonClass::Setup => "setup",
130        }
131    }
132
133    /// Parse a policy-facing token back to a class; `None` if it names no class.
134    pub fn from_token(t: &str) -> Option<ReasonClass> {
135        Some(match t {
136            "reflect" => ReasonClass::Reflect,
137            "dispatch" => ReasonClass::Dispatch,
138            "indirect" => ReasonClass::Indirect,
139            "native" => ReasonClass::Native,
140            "unresolved" => ReasonClass::Unresolved,
141            "setup" => ReasonClass::Setup,
142            _ => return None,
143        })
144    }
145
146    /// Map a raw `unknown_why` reason to its normative class — prefix-based (raw reasons carry a
147    /// `kind:detail` shape, e.g. `dispatch:foo::Bar`), unrecognized → `Unresolved`. Byte-identical
148    /// intent to the java `ReasonClass.classify(String)`.
149    ///
150    /// ⟨0.24⟩ THIS IS THE ONLY PLACE THIS ENGINE HOLDS SPEC §4's KIND VOCABULARY. Every other reference
151    /// to a kind is either a raw string being emitted or the `dispatch:` prefix test in candor-query's
152    /// dispatch frontier; there is no typed kind enum here. §4's "AN ENGINE HOLDS THIS VOCABULARY TWICE,
153    /// AND THE HALVES DRIFT" paragraph records the JVM engine classifying `ambiguous` correctly HERE
154    /// while its typed `Kind` enum lacked the kind entirely — one token, two answers, inside one engine,
155    /// concealed precisely because this half was right. Holding it once is why that is not reachable in
156    /// this engine; a future typed representation must be added at the same commit as its control
157    /// (`off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all`).
158    ///
159    /// The five §4 kinds are `reflect`/`native`/`dispatch`/`callback`/`ambiguous`. `ambiguous` maps to
160    /// `dispatch` and rust is its only PRODUCER; `indy`/`task-handoff` are candor-java's migration kinds
161    /// and `dep:`/`dep-stale:` are swift's registered per-dependency-ENTRY kinds, reaching `Unresolved`
162    /// through the catch-all, which is the class §6.2 prescribes for them.
163    ///
164    /// ⟨0.35⟩ **AND THIS ENGINE PRODUCES A SIXTH SPELLING OF ITS OWN, `macro:`, WHICH THE PARAGRAPH
165    /// ABOVE DID NOT NAME UNTIL SOUNDNESS R270.** It is off-vocabulary as a §4 kind — legal under §2
166    /// forward-compatibility, round-tripped verbatim, and reaching `Unresolved` through the catch-all
167    /// below, deliberately: it marks the ZERO-READABLE-DEFS case (a macro this engine declined to
168    /// expand), which is "a limit of its own resolution rather than an ambiguity in the program" in
169    /// §4 ⟨0.25⟩'s words, and `dispatch` is the wrong class for that. Four sites emit it, and they are
170    /// listed here rather than left to a grep because the whole hazard this doc comment exists for is a
171    /// vocabulary held in one place going stale while the code moves:
172    ///
173    ///   * `collector.rs` `macro:unexpanded multi-arm macro_rules! <name>`   (R257)
174    ///   * `collector.rs` `macro:unreadable macro_rules! template <name>`    (R257)
175    ///   * `collector.rs` `macro:re-export key a macro-hidden module could also own`  (R270)
176    ///   * `scan.rs`      `macro:module items hidden by an unexpanded macro`  (R270, R128's site)
177    ///
178    /// R257 minted the spelling at exactly the two lines it was reported for and this comment kept
179    /// asserting the file held the vocabulary once; R270 is the sweep, and the census that justified it
180    /// enumerates every `ambiguous:`-emitting site rather than the ones in hand. The six that REMAIN
181    /// `ambiguous:` are each genuinely two-separately-written-definitions — including
182    /// `ambiguous:same-name macro_rules! definitions`, which a sweep keyed on the WORD "macro" would
183    /// have re-kinded wrongly and cost 15 crates their `deny Unknown[dispatch]` gate. The kind is
184    /// decided by the CONDITION (two readable defs vs none), never by the name.
185    ///
186    /// A dedicated §4 kind for this state is a SPEC clause plus a conformance PART before any engine
187    /// emits it, and candor-rust does not own that file — filed, not invented here (SOUNDNESS R489).
188    /// And the choice is not free: NONE of the five canonical kinds projects to `unresolved`, so mapping
189    /// `macro:` onto any of them MOVES its class. R270 priced that in both directions and it is the
190    /// measurement that keeps the kind where it is.
191    ///
192    /// ⟨R490⟩ **AND THE PARAGRAPH ABOVE WAS STILL COUNTING ONLY THE SPELLINGS IN THIS CRATE.** The
193    /// nightly dylint lint at the repo root is a SECOND PRODUCER, and it held three more kinds outside
194    /// §4's five (`generic-iter:`, `iter-combinator:`, `deref:`) plus two `dispatch:` details spelled as
195    /// rust PATHS. It now builds every reason through `Kind` above, and
196    /// `candor-scan/tests/source_hygiene.rs` asserts it spells none of its own.
197    pub fn classify(why: &str) -> ReasonClass {
198        let w = why.trim().to_ascii_lowercase();
199        // THE FIVE §4 KINDS COME FROM `Kind::ALL`, NOT FROM A SECOND COPY OF THE FIVE STRINGS
200        // (SOUNDNESS R490). Every other arm below is an ALIAS this engine accepts from a sibling
201        // producer — swift's `closure`/`dynamicmemberlookup`, java's `indy`/`task-handoff` migration
202        // kinds, ts's `missing-config`/`no-tsconfig`/`no-node_modules` setup kinds — and each is
203        // deliberately spelled here and nowhere else, because they are things this engine READS and
204        // never WRITES. The canonical five are checked FIRST so an alias can never shadow one.
205        if let Some(k) = Kind::ALL.into_iter().find(|k| w.starts_with(k.token())) {
206            return k.class();
207        }
208        // SOUNDNESS R1218 — A PREFIX, LIKE EVERY OTHER ARM. This was `w == "dynamicmemberlookup"`, an
209        // EQUALITY test, and swift does not emit the bare word: it emits `dynamicMemberLookup:<Root>.<prop>`.
210        // §6.2's table lists "raw `unknownWhy` PREFIXES", so the real token fell through to the catch-all
211        // and a reflect hole was gated as `unresolved`. MEASURED with this engine's `gate --report` over
212        // candor-swift's own report of a `@dynamicMemberLookup` read: `deny Unknown[reflect]` exit 0
213        // (swift's own gate: 1) and `deny Unknown[unresolved]` exit 1 (swift: 0).
214        if w.starts_with("dynamicmemberlookup") {
215            ReasonClass::Reflect
216        } else if w.starts_with("closure") || w.starts_with("task-handoff") {
217            ReasonClass::Indirect
218        } else if w.starts_with("indy") {
219            ReasonClass::Dispatch
220        } else if w.starts_with("missing-config") || w.starts_with("no-tsconfig") || w.starts_with("no-node_modules") {
221            ReasonClass::Setup
222        } else {
223            ReasonClass::Unresolved
224        }
225    }
226
227    /// The `dynamic` alias — every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` (the
228    /// catch-all) so `Unknown[dynamic]` never under-gates. The design's recommended usable strict gate.
229    pub fn dynamic_set() -> BTreeSet<ReasonClass> {
230        [
231            ReasonClass::Reflect,
232            ReasonClass::Dispatch,
233            ReasonClass::Indirect,
234            ReasonClass::Native,
235            ReasonClass::Unresolved,
236        ]
237        .into_iter()
238        .collect()
239    }
240}
241
242/// SPEC §6.2 ⟨0.24⟩ — does a function's TRANSITIVE reason-class set intersect `want` (class tokens)?
243///
244/// THE `None`/EMPTY ARM IS THE FAIL-CLOSED NET, and it is why this lives here rather than being
245/// open-coded twice. §6.2: "a function whose `Unknown` carries no recorded reason CONTRIBUTES
246/// `unresolved` to its class set — so a narrowed filter never *silently* tolerates a hole it failed to
247/// classify." Read the other way round, `!contains ⇒ exclude` over an empty set drops the entry from
248/// EVERY filter including one naming its own class: a silent under-report wearing a filter.
249///
250/// `classes` is the ACCUMULATED (post-`propagate_str`) set, never the direct `unknownWhy`: a reason
251/// names a site in the function's OWN body (§4), so a function whose `Unknown` is purely inherited
252/// carries none, and matching against the direct field answers a different question. SHARED by the
253/// `deny E Unknown[class]` gate (candor-scan) and `unverified --class` (candor-query) so a gate and the
254/// disclosure explaining it cannot disagree about which holes a class names.
255///
256/// The empty arm is a NET, not a route: it keys on the ABSENCE of a class set, so any other reason on
257/// the same function hides whatever it was covering. The reasonless case that CAN co-occur with a
258/// reason — a direct `Unknown` the unit did not name — must therefore contribute `unresolved` into the
259/// DIRECT map before propagation (candor-scan's `unknown_via_dep`, candor-query's report-side signature),
260/// not arrive here by absence.
261pub fn reason_class_matches(classes: Option<&BTreeSet<String>>, want: &BTreeSet<&str>) -> bool {
262    match classes {
263        None => want.contains(ReasonClass::Unresolved.token()),
264        Some(cs) if cs.is_empty() => want.contains(ReasonClass::Unresolved.token()),
265        Some(cs) => cs.iter().any(|t| want.contains(t.as_str())),
266    }
267}
268
269/// Parse `unknown-alias <name> = <class,…>` lines from `.candor/config` text (⟨0.19⟩, SPEC §6.2) into a
270/// name→classes map. A name that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped (a
271/// config alias may not redefine a built-in), as is a definition naming no valid class. Byte-shape with the
272/// java reference `Config.addAlias`.
273pub fn parse_unknown_aliases(config_text: &str) -> std::collections::BTreeMap<String, BTreeSet<ReasonClass>> {
274    let mut out = std::collections::BTreeMap::new();
275    for raw in config_text.lines() {
276        let line = raw.split('#').next().unwrap_or("").trim();
277        if line.is_empty() {
278            continue;
279        }
280        let mut it = line.splitn(2, char::is_whitespace);
281        // Case-INSENSITIVE key match, like the config loaders in java/ts/swift (which lowercase the key) —
282        // a case-sensitive match here made `Unknown-Alias …` define an alias everywhere BUT rust (a four-way
283        // parse divergence; caught in review). The rest of the line (name + classes) stays case-sensitive.
284        if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("unknown-alias")) {
285            continue;
286        }
287        let val = it.next().unwrap_or("").trim();
288        let Some((name, classes)) = val.split_once('=') else {
289            eprintln!("candor: ignoring `unknown-alias` (want `unknown-alias <name> = <class,…>`): {val}");
290            continue;
291        };
292        let name = name.trim();
293        if name.is_empty() || name == "*" || name == "dynamic" || ReasonClass::from_token(name).is_some() {
294            eprintln!("candor: ignoring `unknown-alias` with reserved/empty name `{name}` (may not shadow `*`/`dynamic`/a class token)");
295            continue;
296        }
297        let mut set = BTreeSet::new();
298        let mut bad: Vec<&str> = Vec::new();
299        for cn in classes.split(',') {
300            let cn = cn.trim();
301            if cn.is_empty() {
302                continue;
303            }
304            if cn == "dynamic" {
305                set.extend(ReasonClass::dynamic_set());
306            } else if let Some(rc) = ReasonClass::from_token(cn) {
307                set.insert(rc);
308            } else {
309                bad.push(cn);
310            }
311        }
312        // ⟨0.24⟩ **AN UNRECOGNISED TOKEN REFUSES THE WHOLE DEFINITION** — SPEC §6.2 `be0b9a9`: the rule
313        // binds every policy value list, and *"the second is the sharper one: the typo is in the
314        // vocabulary the policy is written against rather than in the policy itself, and it fails open
315        // identically."*
316        //
317        // MEASURED: `unknown-alias corp = dispatch,nativ` → the DEFINITION silently became `{dispatch}`,
318        // so `deny Unknown[corp]` exited 0 over a native-caused hole that `= dispatch,native` catches.
319        // The alias resolved, `used_aliases` recorded it, the verdict named the config — every disclosure
320        // fired correctly ABOUT A DEFINITION THAT WAS NOT THE ONE ON DISK.
321        //
322        // REFUSING THE DEFINITION rather than minting a new error channel is what makes this fit: an
323        // alias that does not exist is one the policy's own `Unknown[<name>]` cannot resolve, so it lands
324        // on the `errors` path already there and the gate routes refuse with exit 2 — naming the token
325        // AND the accepted set, as §6.2 requires. It also keeps the blast radius honest: a config
326        // defining ten aliases, one of them typo'd and NONE of them mentioned by this policy, changed
327        // nothing, and turning that into a red gate would be the mirror over-reach. The refusal is
328        // triggered by USE, exactly like `used_aliases` (recorded at the point of use, for the same
329        // reason).
330        if !bad.is_empty() {
331            eprintln!(
332                "candor: REFUSING `unknown-alias {name}` — it names unrecognised reason-class(es) `{}` \
333                 (accepted: reflect, dispatch, indirect, native, unresolved, setup, plus `dynamic`). The \
334                 definition is refused WHOLE rather than narrowed to the tokens that parsed: keeping the \
335                 rest would silently redefine `{name}` as something narrower than the config says, and a \
336                 policy using it would gate less than it reads. `{name}` is now undefined, so a policy \
337                 naming it is a policy error (exit 2).",
338                bad.join("`, `")
339            );
340        } else if set.is_empty() {
341            eprintln!("candor: ignoring `unknown-alias {name}` — no valid reason-class");
342        } else {
343            out.insert(name.to_string(), set);
344        }
345    }
346    out
347}
348
349/// ⟨0.20⟩ Parse `net-partner <host>` lines from `.candor/config` (NET-DESTINATION-CLASS-DESIGN.md): the
350/// per-project set of business-partner hosts the `Net` destination-class classifier treats as
351/// `known-partner`. Multi-value (repeatable key); the value is host-normalized (`:port` stripped,
352/// lowercased) like `MODEL_HOSTS`. Case-insensitive key match, mirroring `parse_unknown_aliases` + the
353/// java/ts/swift config loaders. A partner is per-project — never a universal list.
354pub fn parse_net_partners(config_text: &str) -> BTreeSet<String> {
355    let mut out = BTreeSet::new();
356    for raw in config_text.lines() {
357        let line = raw.split('#').next().unwrap_or("").trim();
358        if line.is_empty() {
359            continue;
360        }
361        let mut it = line.splitn(2, char::is_whitespace);
362        if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("net-partner")) {
363            continue;
364        }
365        let val = it.next().unwrap_or("").trim();
366        if val.is_empty() {
367            continue;
368        }
369        // ⟨0.29⟩ A MALFORMED VALUE IS DISCLOSED, NOT SILENTLY KEPT AS JUNK. The grammar is
370        // `net-partner <host>`; the `=` spelling an operator reaches for by habit
371        // (`net-partner = partner.example`) parsed as the HOST `"= partner.example"`, which entered the
372        // set and matched nothing for the rest of the run. The direction is SAFE — the gate stays armed,
373        // so nothing is certified that should not be — which is precisely why it sat unnoticed: the
374        // operator believes a partner is declared, the verdict disagrees, and no line connects the two.
375        // ⟨0.28⟩ gave POLICY files an `ignored` block for exactly this; config files had none.
376        if val.contains(char::is_whitespace) || val.starts_with('=') {
377            eprintln!(
378                "candor: net-partner takes a bare host — `net-partner <host>`, one per line; \
379                 '{val}' is not one and was IGNORED (an '=' or extra words is the usual cause) — {}",
380                raw.trim()
381            );
382            continue;
383        }
384        out.insert(host_part(val).to_ascii_lowercase());
385    }
386    out
387}
388
389/// Discover `.candor/config` text for a policy/scan anchored at `start`: `$CANDOR_CONFIG` if set + readable,
390/// else the nearest `.candor/config` walking UP from `start`, else `None`. Read-only + lenient (no
391/// process-exit — the caller decides fail-closed); used to resolve `unknown-alias` for the §6.2 gate +
392/// `parsepolicy` so both reflect the same checked-in config.
393pub fn discover_config_text(start: &std::path::Path) -> Option<String> {
394    discover_config(start).map(|(_, t)| t)
395}
396
397/// THE RUNNING BINARY'S REFUSAL WRITER, registered by whichever entry armed a sink.
398///
399/// (These paragraphs once sat ABOVE this static while documenting `discover_config`, which is below it —
400/// so rustdoc rendered that function's path/canonicalization rationale as a description of this hook.
401/// They now live on the item they describe: a doc block belongs to the next item, and separating it with
402/// a blank line is not a fix — clippy's `empty_line_after_doc_comments` rejects that, and CI said so.)
403///
404/// [`discover_config`] is SHARED and sits BELOW every gate sink, so its `exit(2)` for an unreadable
405/// config could not write the refusal document `--gate-json` was promised. Measured: on the `gate`
406/// verb route an unreadable `CANDOR_CONFIG` exited 2 with an EMPTY stream in candor-scan and
407/// candor-ts while candor-java and candor-swift wrote the refusal. The FILE sink was covered — the
408/// armed placeholder survives an exit that writes nothing — so only the stream, which cannot be
409/// pre-armed, was exposed.
410///
411/// NOT registered at startup: `set_refusal_sink` is called by the gate entries that arm a sink, so a
412/// process that never arms one still exits 2 plainly. The comment inside `discover_config` already
413/// records this cause being fixed once, for the EXIT
414/// CODE: the scan route refused and the query route did not. That fix stopped at the exit code and
415/// left the machine channel, which is exactly the split conformance PART 35 and PART 36 exist to
416/// keep apart. A hook rather than a Result because every caller of this function wants the same
417/// answer — refuse through whatever sink this process armed — and threading a new error type through
418/// all of them would create the second copy of a rule that this project keeps getting bitten by.
419static REFUSAL_SINK: std::sync::OnceLock<fn(&str) -> !> = std::sync::OnceLock::new();
420
421/// Register the process's refusal writer. Idempotent; the first registration wins.
422pub fn set_refusal_sink(f: fn(&str) -> !) {
423    let _ = REFUSAL_SINK.set(f);
424}
425
426/// Exit 2 through the registered sink when there is one, and plainly when there is not — a library
427/// consumer that never armed a sink must still get the documented exit code.
428fn refuse_unevaluable(reason: &str) -> ! {
429    if let Some(f) = REFUSAL_SINK.get() {
430        f(reason)
431    }
432    std::process::exit(2)
433}
434
435/// ⟨0.24⟩ As [`discover_config_text`], but ALSO the PATH the text came from, canonicalized.
436///
437/// **WHY THE PATH IS NOW LOAD-BEARING** (SPEC §3.1): a `.candor/config` supplying `unknown-alias`
438/// vocabulary can move a verdict 0→1, and discovery walks PARENT DIRECTORIES, so a file anywhere above
439/// the policy participates — ambient, and until now invisible in the output. *"A verdict changed by a
440/// file the operator cannot see named in the output is the ambient-input failure this whole format
441/// exists to refuse; the remedy is the same one used everywhere else here — not to forbid the input, but
442/// to make it impossible for it to act unnamed."* So the gate document NAMES it, and the path has to
443/// travel out of discovery for that to be possible.
444///
445/// CANONICALIZED because the two routes reach the same file from different working directories, and
446/// §3.1's byte-equality MUST is about the DOCUMENT: a relative path would differ between them for no
447/// reason other than where each was invoked.
448pub fn discover_config(start: &std::path::Path) -> Option<(std::path::PathBuf, String)> {
449    let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
450    // CONFIGURED-BUT-UNUSABLE FAILS LOUD, ON THIS ROUTE TOO. `.ok()` turned an unreadable config into
451    // "no config", so the run continued WITHOUT whatever it declared — a policy, a baseline, an engine
452    // pin, an `unknown-alias` vocabulary. The SCAN route already refuses; this QUERY route did not, so
453    // `gate --report R --policy P` with a broken CANDOR_CONFIG exited 1 here and 2 in java and ts on the
454    // same input. §3.4's posture does not vary by verb.
455    if let Ok(p) = std::env::var("CANDOR_CONFIG") {
456        let p = std::path::PathBuf::from(p);
457        match std::fs::read_to_string(&p) {
458            Ok(t) => return Some((canon(&p), t)),
459            Err(e) => {
460                eprintln!("candor: CANDOR_CONFIG set but {} could not be read ({e}) — failing (exit 2,", p.display());
461                eprintln!("        unevaluable). A config that cannot be read is a guard the operator believes is on.");
462                refuse_unevaluable(&format!("CANDOR_CONFIG set but {} could not be read ({e})", p.display()));
463            }
464        }
465    }
466    let start = canon(start);
467    let mut cur = if start.is_dir() { Some(start.as_path()) } else { start.parent() };
468    while let Some(d) = cur {
469        let cand = d.join(".candor/config");
470        if cand.is_file() {
471            match std::fs::read_to_string(&cand) {
472                Ok(t) => return Some((canon(&cand), t)),
473                Err(e) => {
474                    eprintln!("candor: {} exists but could not be read ({e}) — failing (exit 2,", cand.display());
475                    eprintln!("        unevaluable). Treating it as absent would run without what it declares.");
476                    refuse_unevaluable(&format!("{} exists but could not be read ({e})", cand.display()));
477                }
478            }
479        }
480        cur = d.parent();
481    }
482    None
483}
484
485/// One `deny <Effect…> [scope]` / `pure <scope>` rule (AS-EFF-006). `effects` empty ⇒ a `pure` rule
486/// (ANY effect forbidden). `scope` is a path segment-scope the rule applies to (None = whole unit).
487#[derive(Debug, Clone)]
488pub struct PolicyRule {
489    pub effects: BTreeSet<&'static str>,
490    pub scope: Option<String>,
491    /// Reason-class filter on an `Unknown` membership (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒
492    /// `Unknown[*]` (any reason — the bare form); non-empty ⇒ the Unknown hit fires ONLY for a fn whose
493    /// (transitive) reason classes include one of these. Ignored when `effects` doesn't contain `Unknown`.
494    pub unknown_classes: BTreeSet<ReasonClass>,
495    /// Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
496    /// (any destination — the bare form); non-empty ⇒ the Net hit fires ONLY for a fn whose (transitive)
497    /// destination classes include one of these. Ignored when `effects` doesn't contain `Net`.
498    pub net_classes: BTreeSet<String>,
499    pub raw: String,
500}
501
502/// ⟨0.33⟩ ONE `deny`/`pure` RULE IN ITS CANONICAL EXPANDED FORM — the §6.2 spelling of the rule as THE
503/// MATCHER USED it, after alias resolution (`unknown_classes` is already the resolved token set, not the
504/// raw `Unknown[<alias>]` text).
505///
506/// This is what SPEC §2 ⟨0.33⟩'s `scannedUnder.deny` records: the deny set a policy-scanned peek was
507/// BOUNDED BY, so a consumer gating with a different deny set can tell whether the producer was even
508/// asked its question (candor-java's `Policy.canonicalDenyRule` is the reference sibling — this is the
509/// same rendering, ported).
510///
511/// **NOT effect NAMES.** `pure` is a rule with an EMPTY effect set ("every effect except `Unknown`"), so
512/// flattening the rule to a set of names loses it entirely and a flattened STRICTEST policy compares
513/// equal to the EMPTY set — the four-way false all-clear ⟨0.30⟩ closed on the peek itself, one layer in.
514///
515/// **NOT the raw line.** §3.1 already records that alias expansion breaks byte-equality: two configs
516/// defining `unknown-alias corp` DIFFERENTLY give the raw line `deny Unknown[corp]` two meanings, so
517/// comparing raw text would read a producer that asked the WEAKER question as having answered the
518/// stronger one. Recording the EXPANDED, resolved form is the only rendering that is one meaning per
519/// string.
520pub fn canonical_deny_rule(r: &PolicyRule) -> String {
521    let suffix = r.scope.as_deref().map(|s| format!(" {s}")).unwrap_or_default();
522    if r.effects.is_empty() {
523        return format!("pure{suffix}");
524    }
525    // `effects` is a `BTreeSet<&'static str>`, so this iterates in one fixed (alphabetical) order for
526    // any given set — deterministic across calls and across which order the policy TEXT listed them in,
527    // which is what lets `canonical_deny_set` below produce ONE string per rule regardless of how the
528    // operator wrote it.
529    let parts: Vec<String> = r
530        .effects
531        .iter()
532        .map(|&e| {
533            if e == "Unknown" && !r.unknown_classes.is_empty() {
534                let mut toks: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
535                toks.sort_unstable();
536                format!("Unknown[{}]", toks.join(","))
537            } else if e == "Net" && !r.net_classes.is_empty() {
538                // `net_classes` is a `BTreeSet<String>`, already code-point sorted.
539                format!("Net[{}]", r.net_classes.iter().cloned().collect::<Vec<_>>().join(","))
540            } else {
541                e.to_string()
542            }
543        })
544        .collect();
545    format!("deny {}{suffix}", parts.join(" "))
546}
547
548/// ⟨0.33⟩ A rule LIST as a canonical SET — [`canonical_deny_rule`] over every entry, deduplicated and
549/// code-point sorted (`BTreeSet<String>`'s own order, the same collation the verdict's `zeroMatch` list
550/// uses), so one policy produces ONE document however its lines were ordered and a consumer's SUBSET
551/// test (SPEC §2 ⟨0.33⟩) is a plain membership test rather than an order-sensitive comparison.
552pub fn canonical_deny_set(rules: &[PolicyRule]) -> Vec<String> {
553    rules.iter().map(canonical_deny_rule).collect::<BTreeSet<String>>().into_iter().collect()
554}
555
556/// One `allow <Effect> [in <scope>] <literal>…` rule (AS-EFF-008). The effect is one of the four
557/// that carry a literal surface (`Net` hosts / `Exec` commands / `Fs` paths / `Db` tables); a
558/// function in `scope` performing it may reach ONLY the listed literals. Matching is
559/// effect-specific (`literal_allowed`).
560#[derive(Debug, Clone)]
561pub struct AllowRule {
562    pub effect: &'static str,
563    pub scope: Option<String>,
564    pub literals: BTreeSet<String>,
565    pub raw: String,
566}
567
568/// One `forbid <A> -> <B>` module-layering rule (AS-EFF-009): a function in scope `A` must not
569/// transitively call into scope `B`.
570#[derive(Debug, Clone)]
571pub struct LayerRule {
572    pub from: String,
573    pub to: String,
574    pub raw: String,
575}
576
577/// ⟨0.29⟩ One `only <A> -> <B> [<C> …]` PERMISSION rule (AS-EFF-011): a function in scope `A` may reach
578/// `A` itself and the listed scopes, and NOTHING else.
579///
580/// **`forbid` FAILS OPEN; `only` FAILS SAFE, and that is the whole reason the form exists.** A dependency
581/// you forgot to prohibit is silently permitted, so "this package is a leaf" can only be spelled by
582/// enumerating what it must not reach — a list that does not cover a package added later, and nothing says
583/// so. That is the allowlist hazard this project refuses everywhere in the analysis, living in the POLICY
584/// LANGUAGE instead. Under `only`, a dependency you forgot to permit is a loud violation.
585///
586/// Found by pointing candor's own architecture gate at candor: the natural
587/// `forbid io.poly.candor.model -> io.poly.candor` SELF-FIRES at 58 violations, because a scope matches a
588/// contiguous run of segments and `model` sits under the very prefix it is trying to protect itself from.
589///
590/// THE WALK STOPS AT A PERMITTED SCOPE, and that is a semantic decision rather than an optimisation: a
591/// permitted callee's own dependencies are governed by the rules about IT, not by this one. Descending
592/// past it would make `only` require the transitive closure of everything you permit — which is the
593/// enumeration-that-rots this form exists to replace, one level down.
594#[derive(Debug, Clone)]
595pub struct OnlyRule {
596    /// The scope being constrained. It may always reach ITSELF: an explicit `A -> A` would be noise, and
597    /// without the implicit permission the form is unusable for exactly the case it exists for.
598    pub from: String,
599    /// The scopes `from` may reach. At least one — `only A ->` with nothing after the arrow is DROPPED as
600    /// malformed rather than read as "A may reach nothing at all", which is a different rule (and one a
601    /// reader is far more likely to have typed by accident than to have meant).
602    pub to: Vec<String>,
603    pub raw: String,
604}
605
606/// ⟨0.41⟩ A RULE LINE'S KEY: its RAW lexed tokens (comment removed, ASCII-whitespace runs collapsed) joined by
607/// single spaces — the text an `accept` entry's `for … because` span must equal token for token (SPEC §6.2
608/// ⟨0.41⟩), and the `policyRule` a verdict item carries. The RAW tokens, not the tokens the rule kind consumed:
609/// `deny Fs dao extra` parses as `deny Fs dao`, and only `for deny Fs dao extra` names it.
610///
611/// Takes the rule's `raw` (already comment-stripped and trimmed by the parser) and re-lexes it with the parser's
612/// own separator, so `deny   Fs\tdao` and `deny Fs dao` are one key and `deny\u{a0}Fs` is not two tokens.
613pub fn rule_key(raw: &str) -> String {
614    raw.split(is_ascii_ws).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ")
615}
616
617/// ⟨0.41⟩ IS THIS `fn` A POSITIONAL KEY — one an engine assigns by ordinal position rather than by a declared
618/// name (SPEC §6.2 ⟨0.41⟩: "a property it checks, not a pattern the document lists")? In this engine's own
619/// spellings that is any `{<kind>#<N>}` segment the compiler numbers: `{closure#0}`, `{async_block#1}`,
620/// `{constant#2}`, `{impl#3}`. Inserting one unrelated closure renumbers every later one in the function, so an
621/// entry keyed on it would silently accept DIFFERENT code after the edit. Over-matching errs RED: the line is
622/// ignored and the violation stays.
623pub fn is_positional_key(func: &str) -> bool {
624    let b = func.as_bytes();
625    let mut i = 0;
626    while i < b.len() {
627        if b[i] == b'{' {
628            // `{` <ident chars>+ `#` <digits>+ `}`
629            let mut j = i + 1;
630            while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
631                j += 1;
632            }
633            if j > i + 1 && j < b.len() && b[j] == b'#' {
634                let mut k = j + 1;
635                while k < b.len() && b[k].is_ascii_digit() {
636                    k += 1;
637                }
638                if k > j + 1 && k < b.len() && b[k] == b'}' {
639                    return true;
640                }
641            }
642        }
643        i += 1;
644    }
645    false
646}
647
648/// ⟨0.41⟩ ONE VALID `accept` LINE (SPEC §6.2 ⟨0.41⟩): `accept <fn> [<Effect>…] for <rule tokens…> because <why…>`.
649/// Built only when every check passed — a line that is otherwise malformed is an [`PolicyError`] (non-fatal,
650/// listed in the verdict's `ignored`) and no entry, so the violation it meant to accept stays in `violations`.
651#[derive(Debug, Clone)]
652pub struct AcceptEntry {
653    /// 1-based line of the `accept` line in the policy file.
654    pub line: usize,
655    /// The source line verbatim.
656    pub text: String,
657    /// ONE token, compared by exact string equality with a verdict row's `fn`.
658    pub func: String,
659    /// The §1 effect names listed (and `Unknown`, only with a class filter). Empty for a `forbid` entry.
660    pub effects: BTreeSet<&'static str>,
661    /// The reason-class TOKENS an `Unknown[…]` filter listed. `None` when the entry does not list `Unknown`.
662    pub classes: Option<BTreeSet<String>>,
663    /// [`rule_key`] of the `deny`/`pure`/`forbid` line it names.
664    pub rule_key: String,
665    /// True when the named rule is a `forbid` (AS-EFF-009); false for `deny`/`pure` (AS-EFF-006).
666    pub forbid: bool,
667    /// The operator's justification: its tokens joined by single spaces.
668    pub because: String,
669}
670
671/// ⟨0.24⟩ ONE POLICY LINE THE PARSER DID NOT HONOUR AS WRITTEN (SPEC §3.1 `901f14d` / `195d45a`).
672///
673/// **THE DEFECT.** `parsepolicy` emitted **no `errors` key at all** (measured 2026-07-28 on the
674/// conformance battery: java 10, ts 4, rust 0, swift 0). Every one of these facts existed — they were
675/// printed to stderr as "ignoring policy rule …" — so the verb whose entire purpose is to let a consumer
676/// diff what an engine made of a policy answered the question with the not-honoured half deleted. Worse,
677/// it was INCONSISTENT with this engine's own gate: the gate refuses an unrecognised class token while
678/// the parse narrowed it silently, which is two answers to one question.
679///
680/// **`kind` IS A CLOSED SET, AND IT IS THE SPEC'S, NOT THE REFERENCE ENGINE'S.** `901f14d` pins four
681/// values: `reason-class/alias`, `Net destination-class`, `effect-name`, `rule-kind`. Measured, candor-java
682/// emits `forbid form`, `allow values` and `rule kind` (space, not hyphen) — three values outside the set
683/// and one spelling divergence — and candor-ts renames `kind`→`vocabulary` and `rule`→`where` and emits
684/// `accepted` as a PROSE STRING. This engine follows the clause: a line that names a rule keyword but does
685/// not form that keyword's rule formed no rule kind, so it is `rule-kind`.
686///
687/// `accepted` is an ARRAY OF TOKENS — the tokens that WOULD have been honoured in the position the bad one
688/// occupies. Empty where the position is open-ended (a host, a path), which is a fact about the grammar
689/// rather than a gap in the report.
690#[derive(Debug, Clone)]
691pub struct PolicyError {
692    /// One of [`PolicyError::KIND_REASON_CLASS`], [`PolicyError::KIND_NET_CLASS`],
693    /// [`PolicyError::KIND_EFFECT_NAME`], [`PolicyError::KIND_RULE_KIND`].
694    pub kind: &'static str,
695    /// The offending token, verbatim. Empty when the position was EMPTY (a missing arrow, an `allow` with
696    /// no values) — which is itself the finding.
697    pub token: String,
698    /// The tokens accepted in that position. Empty ⇒ the position takes an open-ended literal.
699    pub accepted: Vec<String>,
700    /// The raw policy line, verbatim.
701    pub rule: String,
702    /// The human sentence — the same text the stderr channel carries, so the two cannot disagree.
703    pub message: String,
704    /// ⟨0.28⟩ The 1-based SOURCE LINE the error sits on — SPEC §6.2 pins the verdict's `ignored`
705    /// disclosure as `[{ line, text, reason }]`, and a consumer's next action is to go to that line.
706    /// Counted over the normalized text (bare `\r` line breaks count like `\n`, matching the split).
707    pub line: usize,
708    /// ⟨0.28⟩ The source line VERBATIM — before comment-stripping and trimming, unlike `rule`, because
709    /// §6.2's `text` is "the source line, verbatim" and the operator matches it against their file.
710    pub text: String,
711    /// ⟨0.24⟩ Does this error make the policy UNHONOURABLE, so every gate route must refuse (exit 2)?
712    ///
713    /// FATAL and REPORTED are different questions and this field is the only place they are told apart.
714    /// A dropped `nonsense line` is reported and survivable — the rest of the policy means what it says.
715    /// A rewritten `deny Unknown[dispatch,nativ]` is not: the rule that RAN is not the rule that was
716    /// written, and the direction that matters NARROWS it.
717    pub fatal: bool,
718}
719
720impl PolicyError {
721    pub const KIND_REASON_CLASS: &'static str = "reason-class/alias";
722    pub const KIND_NET_CLASS: &'static str = "Net destination-class";
723    pub const KIND_EFFECT_NAME: &'static str = "effect-name";
724    pub const KIND_RULE_KIND: &'static str = "rule-kind";
725}
726
727/// The rule kinds parsed from a CANDOR_POLICY file.
728#[derive(Default, Debug)]
729pub struct ParsedPolicy {
730    pub rules: Vec<PolicyRule>,
731    pub allow_rules: Vec<AllowRule>,
732    pub layer_rules: Vec<LayerRule>,
733    /// ⟨0.29⟩ the `only <A> -> <B> …` permission rules — see [`OnlyRule`]. Kept in their own list rather
734    /// than folded into `layer_rules` because the two read OPPOSITE ways: a `forbid` names what must not
735    /// happen, an `only` names the complete set of what may, so a route that handled one as the other
736    /// would inverse the verdict rather than approximate it.
737    pub only_rules: Vec<OnlyRule>,
738    /// ⟨0.24⟩ POLICY ERRORS — a policy that cannot be honoured AS WRITTEN (SPEC §6.2). Non-empty ⇒ every
739    /// gate route MUST refuse: exit 2, the unreadable-policy posture. Not a warning list: the rules in
740    /// `rules` are what the text would mean if the error were tolerated, and tolerating it is the defect.
741    ///
742    /// Today the only member is an unrecognised reason-class/alias token in an `Unknown[…]` filter. The
743    /// asymmetry that used to justify a warning — "a dropped policy token leaves a WIDER rule standing,
744    /// so the failure is loud" — is false in the case that matters, and the false half is FAIL-OPEN:
745    ///
746    ///   - `deny Unknown[corp]` (sole unrecognised token) — the filter empties and the rule WIDENS to a
747    ///     bare `deny Unknown`, while the engine prints "ignoring policy rule" and then KEEPS and
748    ///     re-scopes it. Merely surprising, but a FALSE DISCLOSURE.
749    ///   - `deny Unknown[dispatch,nativ]` (a typo BESIDE valid tokens) — the token is dropped, the rule
750    ///     NARROWS to `[dispatch]`, and it stops gating native-caused holes entirely while the operator
751    ///     reads a gate that looks armed. **That is the fail-open, and it is the common case: a typo
752    ///     lands beside correct tokens far more often than alone.**
753    ///
754    /// A policy that cannot be honoured as written is not silently rewritten into a different policy.
755    ///
756    /// ⟨0.24⟩ THIS LIST NOW HOLDS EVERY LINE THE PARSER DID NOT HONOUR, fatal or not (SPEC §3.1
757    /// `195d45a`) — `parsepolicy` reports them all, and the gate routes refuse on
758    /// [`ParsedPolicy::fatal_messages`] alone. Widening the LIST without widening what REFUSES is the
759    /// whole of the change: a dropped `nonsense line` was always survivable and stays so.
760    pub errors: Vec<PolicyError>,
761    /// ⟨0.24⟩ The `.candor/config` `unknown-alias` definitions this policy actually resolved a token
762    /// through (SPEC §3.1) — **name → the reason-class TOKENS it expanded to**, not a bare name list.
763    /// Non-empty ⇒ a config file supplied vocabulary that PARTICIPATED in the verdict, and the
764    /// `--gate-json` document MUST name that file. Recorded at the point of USE, not from the alias map:
765    /// a config defining ten aliases none of which the policy mentions changed nothing, and naming it
766    /// would train the reader to ignore the field.
767    ///
768    /// ⟨0.24⟩ **THE VALUE TRAVELS WITH THE NAME, AND THAT IS A SPEC MUST** (§3.1, candor-spec `7f5b5ba`).
769    /// This engine shipped the bare name — as did java and swift — and candor-ts kept the map and won the
770    /// argument from the clause's OWN sentence: `configSources: [path]` is rejected there because *a
771    /// disclosure that names the source but not the content leaves the reader knowing they were affected
772    /// and not how*, and `["corp"]` fails that same test one level down. **`corp = reflect` and
773    /// `corp = reflect,native` gate DIFFERENTLY under one unchanged policy line**, so a reader given only
774    /// the name cannot tell which gate ran. The map is a strict superset — the keys recover the old array.
775    ///
776    /// Class TOKENS rather than `ReasonClass`, so the wire order is the token's alphabetical one (which
777    /// is what candor-ts's `[...set].sort()` produces) and not `ReasonClass`'s declaration order.
778    pub used_aliases: BTreeMap<String, BTreeSet<String>>,
779    /// ⟨0.41⟩ The VALID `accept` entries (SPEC §6.2 ⟨0.41⟩), in source order. Directives, not rules: an entry is
780    /// never counted by the zero-rules check, never fires, and `rules`/`allow_rules`/`layer_rules`/`only_rules`
781    /// are exactly what they were without it. Empty on every policy without an `accept` line, which is what
782    /// keeps every verdict of such a policy byte-identical.
783    pub accepts: Vec<AcceptEntry>,
784    /// ⟨0.41⟩ stamp every policy row with its producing line's key even when the policy holds no `accept` entry — what
785    /// the `accepts` verb needs to say which line produced a row it proposes to adopt. Never set by a parse; the
786    /// verb sets it on its own copy. It changes no verdict: a key is read only by [`crate::accept::settle`], and
787    /// only against entries.
788    pub stamp_rule_keys: bool,
789}
790
791impl ParsedPolicy {
792    /// ⟨0.24⟩ The messages of the errors that make the policy UNHONOURABLE — what every gate route
793    /// refuses on. Non-empty ⇒ exit 2, the unreadable-policy posture.
794    ///
795    /// Separate from `errors` because REPORTED and FATAL are different questions, and conflating them in
796    /// either direction is a defect: refusing on every dropped line would make `nonsense line` fail a
797    /// build, and reporting only the fatal ones is the silent narrowing this rung exists to close.
798    pub fn fatal_messages(&self) -> Vec<&str> {
799        self.errors.iter().filter(|e| e.fatal).map(|e| e.message.as_str()).collect()
800    }
801}
802
803/// The hostname part of a `host[:port]` literal, port stripped — so `api.stripe.com` in a rule accepts
804/// a reached `api.stripe.com:443`. IPv6-aware: a bracketed `[host]:port` yields the bracketed host, and
805/// a BARE IPv6 literal (>1 colon, no brackets) has no port to strip and is returned whole — a naive
806/// first-colon split collapsed every `2001:db8::*` to `2001`, so one allowed IPv6 accepted any address
807/// in that block (/code-review). A hostname/IPv4 `host` or `host:port` (≤1 colon) splits at the colon.
808pub fn host_part(h: &str) -> &str {
809    if let Some(rest) = h.strip_prefix('[') {
810        // `[ipv6]` or `[ipv6]:port` — the host is between the brackets.
811        return rest.split(']').next().unwrap_or(rest);
812    }
813    if h.matches(':').count() > 1 {
814        return h; // bare IPv6 literal — no port suffix to strip
815    }
816    h.split(':').next().unwrap_or(h)
817}
818
819/// The basename of a command (`/usr/bin/git` → `git`), so `allow Exec … git` accepts an absolute path.
820pub fn cmd_base(c: &str) -> &str {
821    c.rsplit(['/', '\\']).next().unwrap_or(c)
822}
823
824/// Whether an allowed path `a` covers a reached path `r` (SPEC §6.2: path-boundary-respecting prefix).
825/// A directory covers itself and everything beneath it, but NOT a sibling sharing a textual prefix
826/// (`/etc/app` ⊉ `/etc/apppwned`); a `..` that climbs out is never covered; absolute/relative are
827/// never conflated.
828pub fn fs_path_covered(a: &str, r: &str) -> bool {
829    if r.split(['/', '\\']).any(|c| c == "..") {
830        return false;
831    }
832    let absolute = |s: &str| s.starts_with('/') || s.starts_with('\\');
833    if absolute(a) != absolute(r) {
834        return false;
835    }
836    let norm = |s: &str| -> Vec<String> {
837        s.split(['/', '\\'])
838            .filter(|c| !c.is_empty() && *c != ".")
839            .map(|c| c.to_string())
840            .collect()
841    };
842    let (ac, rc) = (norm(a), norm(r));
843    ac.len() <= rc.len() && ac.iter().zip(&rc).all(|(x, y)| x == y)
844}
845
846/// Whether an allowed table entry `a` covers a reached table `r` (SPEC §6.2): case-insensitive
847/// exact match on the (possibly schema-qualified) name, or a `schema.*` entry covering every table
848/// in that schema. Strict on qualification — an allowed `entries` does NOT cover a reached
849/// `ledger.entries` (write both forms if your queries mix them); silent widening is the failure
850/// mode an allowlist exists to prevent.
851pub fn db_table_covered(a: &str, r: &str) -> bool {
852    let (a, r) = (a.to_lowercase(), r.to_lowercase());
853    if let Some(schema) = a.strip_suffix(".*") {
854        return r.strip_prefix(schema).is_some_and(|rest| rest.starts_with('.'));
855    }
856    a == r
857}
858
859/// Whether a reached literal is allowed under an effect-specific match (SPEC §6.2): `Net` host by
860/// name (port ignored), `Exec` command by basename, `Fs` path by boundary-respecting prefix,
861/// `Db` table by qualified name or `schema.*`.
862pub fn literal_allowed(effect: &str, reached: &str, allow: &BTreeSet<String>) -> bool {
863    match effect {
864        // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — matched by hostname like `Net`.
865        "Net" | "Llm" => allow.iter().any(|a| host_part(a) == host_part(reached)),
866        "Exec" => allow.iter().any(|a| cmd_base(a) == cmd_base(reached)),
867        "Fs" => allow.iter().any(|a| fs_path_covered(a, reached)),
868        "Db" => allow.iter().any(|a| db_table_covered(a, reached)),
869        _ => allow.contains(reached),
870    }
871}
872
873/// Split a function name (or scope) into PATH SEGMENTS on either separator. Reports reach the Rust gate
874/// AND `candor-query` from BOTH the Rust engines (`::`-separated names) and the JVM/Swift/TS engines
875/// (`.`-separated names — `candor-query` is explicitly built to read them). Segmenting on `::` ALONE
876/// left a scoped `deny`/`pure` rule silently INERT on a dotted name: the scope matched nothing, so
877/// `whatif` returned a false green on the security boundary (gate-evasion). The JVM engine's own
878/// `scopeMatches` already splits on `.`; this aligns the Rust side. A `:`/`.` never appears WITHIN a
879/// real segment, so splitting on both never over-segments a Rust name (no spurious match).
880fn name_segments(s: &str) -> Vec<&str> {
881    s.split(['.', ':']).filter(|p| !p.is_empty()).collect()
882}
883
884/// A policy scope matches a function name by **path segment** (SPEC §6.2), not substring: split both
885/// into segments (on `::` or `.`); the scope matches a contiguous run of name-segments where every
886/// segment except the last matches exactly and the last is a prefix. So `domain` matches
887/// `app::domain::h`, `com.acme.domain.h`, and `domain_logic` but not `subdomain`.
888/// ⟨0.29⟩ SCOPE MATCHING FOR A **PERMISSION**, where the prefix rule is FAIL-OPEN.
889///
890/// [`scope_matches`]'s last segment is a PREFIX of its name-segment, so `util` matches `utilities`. For
891/// `deny`/`pure`/`forbid` that widening is FAIL-CLOSED — a scope that matches more forbids more — and it
892/// is why the rule exists. For the `to` list of an `only` rule it is the exact inverse: a permitted scope
893/// that matches more PERMITS more, so the matcher that keeps every other rule kind safe silently widens
894/// the one form whose entire purpose is to fail safe.
895///
896/// MEASURED on the shipped ⟨0.29⟩ implementation, before this function existed:
897///
898/// ```text
899/// only model -> util          `model::go` reaching `utilities_untrusted::exfil`  →  policy ✓
900/// forbid model -> util        the identical reach                                 →  AS-EFF-009
901/// ```
902///
903/// The operator wrote a complete permission list and the matcher quietly extended it to anything sharing
904/// a prefix with an entry. So a `to` scope matches by EXACT segment run, with no prefix on the last —
905/// `only a -> util` permits `util::x` and `crate::util::x`, and does NOT permit `utilities_untrusted::x`.
906///
907/// THE `from` SIDE KEEPS THE PREFIX RULE, deliberately: `from` selects which functions the rule BINDS, so
908/// matching more constrains more, which is the safe direction. The asymmetry is the point — each side
909/// takes the matcher whose over-approximation errs toward the gate firing.
910pub fn scope_matches_permitted(name: &str, scope: &str) -> bool {
911    let segs = name_segments(name);
912    let parts = name_segments(scope);
913    if parts.is_empty() || parts.len() > segs.len() {
914        return false;
915    }
916    segs.windows(parts.len()).any(|w| w == parts.as_slice())
917}
918
919/// A TRAILING SEPARATOR MEANS EXACT SEGMENT — `app::` matches the segment `app` and nothing else,
920/// where bare `app` keeps the documented prefix behaviour and still matches `application_name`.
921///
922/// REPORTED FROM THE FIELD (ebman CI adoption, 2026-08-23) and reproduced: `forbid aws -> app` fired
923/// 14 times on honest AWS SDK calls because `app` prefix-matched `application_name`, and writing
924/// `app::` did not help — `name_segments` drops empty parts, so `app::` segmented to exactly `["app"]`
925/// and the separator never survived to mean anything. The reporter deleted the rule, which is the real
926/// cost: **the genuine `aws -> app` violation it existed to catch will now never fire, and nothing in
927/// the policy file records that a boundary stopped being checked.** The two available responses to the
928/// false positives — delete the rule, or widen the scope until it stops firing — end in the same place.
929///
930/// ADDITIVE ON PURPOSE. Bare `app` is unchanged, so no existing verdict moves; only `app::`, which
931/// today silently behaves as `app`, starts meaning what everyone who writes it intends. Silence was
932/// the worst of the three options available (match exactly / error as unsupported / quietly do
933/// nothing), because ⟨0.24⟩ §3.1 already rules that an unanswerable condition must be DISCLOSED rather
934/// than scored as satisfied — and a scope token that segments away is exactly that.
935fn scope_is_exact(scope: &str) -> bool {
936    let t = scope.trim_end();
937    t.ends_with("::") || t.ends_with('.')
938}
939
940pub fn scope_matches(name: &str, scope: &str) -> bool {
941    let segs = name_segments(name);
942    let parts = name_segments(scope);
943    if parts.is_empty() || parts.len() > segs.len() {
944        return false;
945    }
946    let exact = scope_is_exact(scope);
947    let (last, init) = parts.split_last().unwrap();
948    segs.windows(parts.len()).any(|w| {
949        let (w_last, w_init) = w.split_last().unwrap();
950        w_init == init && if exact { w_last == last } else { w_last.starts_with(last) }
951    })
952}
953
954/// Reconstruct a rule's source form and the `Unknown`-forbidding upgrade for it: `pure <scope>` →
955/// (`"pure <scope>"`, `"deny Unknown <scope>"`); `deny <E…> <scope>` → (`"deny <E…> <scope>"`,
956/// `"deny <E…> Unknown <scope>"`). Shared so the gate note and `candor unverified` name the identical
957/// rule and upgrade — one source of truth for the disclosure's advice.
958///
959/// ⟨0.24⟩ **THE NARROWING FILTERS ARE RENDERED, and they had to start being rendered in the same commit
960/// that made them REACHABLE here.** Making `unverified_hole_rule` filter-aware is what first lets a
961/// `deny Unknown[reflect]` / `deny Net[unknown-host]` rule be the rule a hole is disclosed under — and
962/// this reconstruction dropped the bracket, so the fix would have printed the operator's narrowed rule
963/// back to them as the WIDE one (`deny Unknown`) and advised the nonsense upgrade `deny Unknown
964/// Unknown`. That is the same mis-attribution `whatif` carries, arriving through the fix for a different
965/// defect: the hazard on this rung is that each correction manufactures its own mirror, so the rendering
966/// moves with the predicate rather than after it.
967///
968/// A rule carrying NO filter renders byte-identically to before, which is what keeps conformance PARTs
969/// 12c/12d (`deny Db Net Unknown domain`, four-way) unmoved.
970///
971/// THE UPGRADE SPLITS on whether the rule already denies `Unknown`. If it does, it can only be here
972/// NARROWED — a bare `deny … Unknown` fires on every `Unknown`, so the function would be a violation and
973/// not a hole — and the upgrade is that term WIDENED to bare `Unknown`, not a second `Unknown` appended.
974pub fn rule_and_upgrade(r: &PolicyRule) -> (String, String) {
975    let scope = r.scope.clone().unwrap_or_default();
976    let suffix = if scope.is_empty() { String::new() } else { format!(" {scope}") };
977    if r.effects.is_empty() {
978        // `pure` forbids real effects but not Unknown; to REQUIRE provable purity, add a deny-Unknown.
979        return (format!("pure{suffix}"), format!("deny Unknown{suffix}"));
980    }
981    // One effect term, with its narrowing filter if it has one. Class tokens sorted by TOKEN string, as
982    // `parsepolicy` sorts them and as the java reference's `.sorted()` does — the dump and the
983    // disclosure must spell one rule one way.
984    let term = |e: &str| -> String {
985        if e == UNKNOWN && !r.unknown_classes.is_empty() {
986            let mut t: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
987            t.sort_unstable();
988            format!("{UNKNOWN}[{}]", t.join(","))
989        } else if e == "Net" && !r.net_classes.is_empty() {
990            format!("Net[{}]", r.net_classes.iter().map(String::as_str).collect::<Vec<_>>().join(","))
991        } else {
992            e.to_string()
993        }
994    };
995    let effs = r.effects.iter().map(|e| term(e)).collect::<Vec<_>>().join(" ");
996    if r.effects.contains(UNKNOWN) {
997        let widened =
998            r.effects.iter().map(|e| if *e == UNKNOWN { UNKNOWN.to_string() } else { term(e) }).collect::<Vec<_>>();
999        (format!("deny {effs}{suffix}"), format!("deny {}{suffix}", widened.join(" ")))
1000    } else {
1001        (format!("deny {effs}{suffix}"), format!("deny {effs} {UNKNOWN}{suffix}"))
1002    }
1003}
1004
1005/// The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
1006/// `Unknown`, sits in a `pure`/`deny <E>` scope, and PASSES that rule (carries none of its forbidden real
1007/// effects) — so its compliance is asserted but not verified (the Unknown could hide the very effect the
1008/// rule forbids; the classic case is a fn/closure-injected port). A *real* violation is the gate's job, not
1009/// this. Returns the first governing rule under which the function is such a hole, or `None` if it is not
1010/// one. Shared by candor-scan's gate note and candor-query's `unverified` so "what a hole is" has ONE
1011/// definition — the two paths can never drift (conformance PART 12d pins their agreement).
1012///
1013/// ⟨0.24⟩ **"PASSES" IS NOW ASKED OF THE GATE, NOT OF A SECOND COPY OF IT.** This predicate computed the
1014/// passing test from `r.effects` alone — "does the rule NAME an effect this function has?" — which is
1015/// the pre-⟨0.19⟩ question, still being asked after two rungs gave rules a NARROWING FILTER. So on
1016/// `deny Unknown[reflect]` over an `indirect` hole the gate TOLERATED (exit 0, the class does not match)
1017/// while this read the same rule as violated; and a hole is *by definition* a function that PASSES its
1018/// rule while `Unknown`, so the real hole was reclassified as a violation-that-isn't and **deleted from
1019/// the disclosure** — `unverified` answering "every function in a pure/deny layer is PROVABLY clean ✓"
1020/// over a function the gate had just declined to clear. MEASURED 2026-07-28, and reachable with no alias
1021/// in play at all: one layer below the widening `ea0df4f` closed, in the same four verbs.
1022///
1023/// It now calls [`crate::gate::rule_hits`] — the gate's own firing decision — so the two cannot disagree
1024/// again. That needs the function's TRANSITIVE reason classes and its ⟨0.20⟩ destination classes, the
1025/// same two accumulators the gate reads, which is why they are parameters now rather than derivable.
1026///
1027/// **THE DIRECTION IS THE MIRROR ARGUMENT.** A filter can only ever SHRINK what a rule charges, so a
1028/// filter-aware pass test can only ever find MORE holes — this cannot silently suppress a disclosure
1029/// that used to appear. Pinned in both directions by
1030/// `a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not`, in one run, because
1031/// the fixture proving the fabrication is closed cannot show the reach closed with it. A WITHHELD filter
1032/// — nothing to read — likewise counts as PASSING here: the gate did not clear the function, and an
1033/// advisory note's fail-safe direction is to disclose.
1034pub fn unverified_hole_rule<'a, S: AsRef<str>>(
1035    name: &str,
1036    effects: &[S],
1037    reason_classes: Option<&BTreeSet<String>>,
1038    net_classes: &[String],
1039    rules: &'a [PolicyRule],
1040) -> Option<&'a PolicyRule> {
1041    if !effects.iter().any(|e| e.as_ref() == UNKNOWN) {
1042        return None;
1043    }
1044    let effs: Vec<&str> = effects.iter().map(AsRef::as_ref).collect();
1045    rules.iter().find(|r| {
1046        // in the rule's scope (a scopeless rule governs the whole unit) …
1047        if let Some(s) = &r.scope {
1048            if !scope_matches(name, s) {
1049                return false;
1050            }
1051        }
1052        // … and PASSES it — the gate's own answer, narrowing filters and all. Empty `hits` IS passing.
1053        crate::gate::rule_hits(r, &effs, reason_classes, net_classes).hits.is_empty()
1054    })
1055}
1056
1057/// R443 — THE PROVABLE-PURITY ADVISORY'S LINES, ONE RENDERER FOR BOTH GATE ROUTES.
1058///
1059/// `candor-scan --policy` printed this note and `candor-query gate --report` printed NOTHING over the
1060/// identical tree, the identical rule and the identical verdict (MEASURED 2026-09-15 on
1061/// `pub fn via_callback(f: impl Fn()) { f(); }` under `pure via_callback`: route B three lines naming
1062/// `deny Unknown via_callback`, route A `policy ✓` and silence). **The silent route is the DEPLOYED
1063/// one** — gating a precomputed report is what the query layer is for, so it is what CI runs.
1064///
1065/// The SET is already single-sourced ([`unverified_hole_rule`], which both routes have always called);
1066/// this is the other half — the rendering, which would otherwise be the same three sentences written
1067/// twice in two crates, i.e. the R288/R347 shape arriving through the fix for it. Only the `prefix`
1068/// differs, because the two routes must name themselves (an operator has to know which command to
1069/// re-run). `holes` is `(fn, upgrade)` in the caller's own order — already the shape candor-scan's
1070/// `unverified_holes` returns.
1071///
1072/// ADVISORY: the caller prints these and does NOT touch its verdict or exit code. Whether `pure`/`deny`
1073/// should FAIL CLOSED on `Unknown` is a SPEC question (R443's expensive half) and is deliberately not
1074/// decided here.
1075pub fn unverified_note_lines(prefix: &str, holes: &[(String, String)]) -> Vec<String> {
1076    if holes.is_empty() {
1077        return Vec::new();
1078    }
1079    let mut out = vec![format!(
1080        "{prefix}: note — {} function(s) PASS the policy but are Unknown (purity NOT verified — the Unknown could hide a forbidden effect):",
1081        holes.len()
1082    )];
1083    for (fq, up) in holes {
1084        out.push(format!("    `{fq}`  → add  `{up}`"));
1085    }
1086    out.push(
1087        "  (advisory; add the upgrade(s) to REQUIRE provable purity, or run `candor-query unverified` for detail — the gate verdict is unchanged)"
1088            .to_string(),
1089    );
1090    out
1091}
1092
1093/// Parse a CANDOR_POLICY file (SPEC §6.2). One rule per line; `#` comments and blanks ignored:
1094///
1095/// ```text
1096/// deny Net Db  domain     # functions whose path contains segment "domain" must not perform Net or Db
1097/// deny Exec               # no function anywhere may perform Exec
1098/// deny Unknown  api        # functions in "api" must be fully resolvable (forbid the unverifiable)
1099/// pure         parse      # functions whose path contains segment "parse" must be effect-free
1100/// allow Net in billing  api.stripe.com
1101/// forbid domain -> infra
1102/// ```
1103///
1104/// In a `deny` rule, leading tokens that name a known effect (or `Unknown`) are forbidden; the FIRST
1105/// non-effect token is the scope and ends the rule. A `deny` naming no known effect is dropped (it is
1106/// NOT a `pure` rule). Malformed/unknown lines are ignored with a warning — never silently widened.
1107/// The §6.2 token separator: ASCII whitespace ONLY (space/tab/CR/LF/VT/FF). `split_whitespace`/`trim`
1108/// use Unicode `White_Space`, which would split a NBSP/ideographic space that Java drops — a gateless-
1109/// green cross-engine divergence (adversarial DSL review). A non-ASCII space stays part of its token, so
1110/// the rule is malformed and ignored, uniformly.
1111fn is_ascii_ws(c: char) -> bool {
1112    matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
1113}
1114
1115pub fn parse_policy(text: &str) -> ParsedPolicy {
1116    parse_policy_impl(text, true, &std::collections::BTreeMap::new())
1117}
1118/// As [`parse_policy`] but with `.candor/config` `unknown-alias` definitions (⟨0.19⟩, SPEC §6.2): an
1119/// `Unknown[<name>]` filter resolves a user-defined `<name>` to its reason classes. The gate + `parsepolicy`
1120/// pass the discovered aliases (via [`parse_unknown_aliases`]); a config alias never changes what bare
1121/// `deny E Unknown` means (always `Unknown[*]`), so the rule stays legible from the policy alone.
1122pub fn parse_policy_with_aliases(text: &str, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
1123    parse_policy_impl(text, true, aliases)
1124}
1125/// ⟨0.24⟩ [`parse_policy_with_aliases`] but SILENT — for a caller that must inspect
1126/// [`ParsedPolicy::errors`] / [`ParsedPolicy::used_aliases`] BEFORE it parses for real (candor-scan
1127/// refuses before touching the classifier's accumulators). Silent so the ordinary parse warnings are not
1128/// printed twice on the same text.
1129pub fn parse_policy_silent(
1130    text: &str,
1131    aliases: &std::collections::BTreeMap<String, BTreeSet<ReasonClass>>,
1132) -> ParsedPolicy {
1133    parse_policy_impl(text, false, aliases)
1134}
1135/// Same as [`parse_policy`] but SILENT about malformed rules — for a SECOND, advisory re-parse within the
1136/// same run (candor-scan parses once for the gate check and again for the `unverified` disclosure), so the
1137/// CI log doesn't print every "ignoring policy rule …" warning twice (#21). The first parse already warned.
1138pub fn parse_policy_quiet(text: &str) -> ParsedPolicy {
1139    parse_policy_impl(text, false, &std::collections::BTreeMap::new())
1140}
1141fn parse_policy_impl(text: &str, warn: bool, aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>) -> ParsedPolicy {
1142    macro_rules! warn_ignore { ($($a:tt)*) => { if warn { eprintln!($($a)*); } } }
1143    let mut out = ParsedPolicy::default();
1144    // ⟨0.28⟩ The source position of the line under the cursor, for `PolicyError::{line, text}` — bound
1145    // BEFORE the macro below so its body (whose free identifiers resolve at definition scope) can read
1146    // them; the loop updates both per iteration. §6.2's `ignored` disclosure is `[{line, text, reason}]`.
1147    let mut cur_line: usize;
1148    let mut cur_text: &str;
1149    // ⟨0.24⟩ Record a line the parser did not honour (SPEC §3.1 `195d45a`), on the ONE list `parsepolicy`
1150    // reports and the gate routes filter for `fatal`. The stderr sentence and `message` are the SAME
1151    // string by construction — a disclosure that can drift from the one beside it is how this family
1152    // produced a FALSE disclosure once already (conformance PART 13b).
1153    macro_rules! not_honoured {
1154        ($fatal:expr, $kind:expr, $token:expr, $accepted:expr, $rule:expr, $msg:expr) => {{
1155            let message: String = $msg;
1156            out.errors.push(PolicyError {
1157                kind: $kind,
1158                token: ($token).to_string(),
1159                accepted: ($accepted).iter().map(|s: &&str| s.to_string()).collect(),
1160                rule: ($rule).to_string(),
1161                message,
1162                line: cur_line,
1163                text: cur_text.to_string(),
1164                fatal: $fatal,
1165            });
1166        }};
1167    }
1168    // `str::lines()` splits on \n and \r\n but NOT bare \r — a classic-Mac file then collapses to ONE
1169    // line, and since \r is also an in-line ASCII-ws token separator (is_ascii_ws), every rule after the
1170    // first was glued into the first rule's tokens and dropped (sweep [16], a gateless-green divergence).
1171    // Java's Files.readAllLines (the reference) breaks on bare \r too — normalize to match it. Allocation
1172    // only when a bare \r is actually present (the overwhelmingly-common \n / \r\n files are untouched).
1173    // ⟨0.40⟩ SOUNDNESS R1224 (SPEC §6.2, the owner's ruling 2026-10-20) — a U+FEFF at BYTE OFFSET 0 is an ENCODING
1174    // MARK, removed before the file is split into lines; it is not a character of the first token. An editor
1175    // that writes a UTF-8 byte-order mark (PowerShell 5.1 `Out-File -Encoding utf8`) otherwise costs the policy
1176    // its FIRST rule: `\u{feff}deny` is no keyword, so a one-rule file refused (exit 2, zero rules) and a
1177    // multi-rule one ran without it. ONE mark, at offset 0 only: a second U+FEFF, or one at the start of a later
1178    // line or inside a token, stays an ordinary token character and its line is ignored-with-a-warning as before.
1179    // Every policy reader in this repo — candor-query's gate/whatif/fix/parsepolicy/gate-verdict, candor-scan's
1180    // `--policy` routes and the nightly lint — parses through this function, so this is the one place.
1181    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1182    let normalized;
1183    let text = if text.contains('\r') {
1184        normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1185        normalized.as_str()
1186    } else {
1187        text
1188    };
1189    // ⟨0.41⟩ `accept` lines, held until every rule line has been read: an entry may name a rule that comes
1190    // AFTER it. (line number, source line verbatim, comment-stripped line.)
1191    let mut pending_accepts: Vec<(usize, &str, &str)> = Vec::new();
1192    for (line_idx, raw_line) in text.lines().enumerate() {
1193        cur_line = line_idx + 1;
1194        cur_text = raw_line;
1195        let line = raw_line.split('#').next().unwrap_or("").trim_matches(is_ascii_ws);
1196        if line.is_empty() {
1197            continue;
1198        }
1199        let mut toks = line.split(is_ascii_ws).filter(|s| !s.is_empty());
1200        match toks.next().unwrap_or("") {
1201            "accept" => pending_accepts.push((cur_line, raw_line, line)),
1202            "allow" => {
1203                let effect = match toks.next().unwrap_or("") {
1204                    "Net" => "Net",
1205                    // `Llm` ⟨0.13⟩ rides the Net host literal (SPEC §1) — `allow Llm <host…>` restricts which
1206                    // MODEL hosts a scope may reach, matched by hostname like Net (its reached surface IS the
1207                    // Net host surface). Match candor-java's Policy.parsePolicy.
1208                    "Llm" => "Llm",
1209                    "Exec" => "Exec",
1210                    "Fs" => "Fs",
1211                    "Db" => "Db",
1212                    other => {
1213                        let msg = format!(
1214                            "unknown effect-name `{other}` in `allow` (accepted: Db, Exec, Fs, Llm, Net \
1215                             \u{2014} `allow` covers only the effects carrying a literal surface: Net/Llm \
1216                             hosts, Exec commands, Fs paths, Db tables): {line}"
1217                        );
1218                        warn_ignore!("candor: policy error — {msg}");
1219                        // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way before this:
1220                        // `allow Nett host.example` -> exit 0 on rust, ts, java AND swift. The rule is
1221                        // DELETED and the certification silently vanishes, so the operator reads an
1222                        // armed allowlist that does not exist.
1223                        //
1224                        // The grammar defence that kept the token rule inside the bracket does NOT
1225                        // reach here: `allow`'s effect position is a fixed, closed set with **no scope
1226                        // reading available**, so an unrecognised token there is unambiguously a typo
1227                        // and there is no legitimate policy it could be. This document already calls a
1228                        // dropped rule "the limit case of silently rewritten into a different policy…
1229                        // a bigger rewrite than a narrowed filter" — and the bigger rewrite was
1230                        // warning-only while the smaller one was already exit 2.
1231                        not_honoured!(
1232                            true,
1233                            PolicyError::KIND_EFFECT_NAME,
1234                            other,
1235                            ["Db", "Exec", "Fs", "Llm", "Net"],
1236                            line,
1237                            msg
1238                        );
1239                        continue;
1240                    }
1241                };
1242                let mut rest: Vec<&str> = toks.collect();
1243                let scope = if rest.first() == Some(&"in") {
1244                    let s = rest.get(1).map(|s| s.to_string());
1245                    rest.drain(..2.min(rest.len()));
1246                    s
1247                } else {
1248                    None
1249                };
1250                let literals: BTreeSet<String> = rest.iter().map(|h| h.to_string()).collect();
1251                if literals.is_empty() {
1252                    let msg = format!("`allow {effect}` names no values: {line}");
1253                    warn_ignore!("candor: ignoring policy rule ({msg})");
1254                    // `accepted` is EMPTY on purpose: the position takes an open-ended literal (a host, a
1255                    // path, a command, a table), so there is no token list to offer. A fact about the
1256                    // grammar, not a gap in the report.
1257                    not_honoured!(false, PolicyError::KIND_RULE_KIND, "", [], line, msg);
1258                    continue;
1259                }
1260                out.allow_rules.push(AllowRule { effect, scope, literals, raw: line.to_string() });
1261            }
1262            "deny" => {
1263                let mut effects = BTreeSet::new();
1264                let mut scope = None;
1265                // Reason-class filter on `Unknown` (REASON-SCOPED-UNKNOWN-DESIGN.md): empty ⇒ `Unknown[*]`
1266                // (any reason — the bare form); non-empty ⇒ only those classes. `*` = all.
1267                let mut unknown_classes: BTreeSet<ReasonClass> = BTreeSet::new();
1268                let mut unknown_star = false;
1269                // Destination-class filter on `Net` (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
1270                // (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
1271                let mut net_classes: BTreeSet<String> = BTreeSet::new();
1272                let mut net_star = false;
1273                for t in toks {
1274                    // `Net[unknown-host]` / `Net[*]` / `Net[known-telemetry,unknown-host]`: the destination-scoped form.
1275                    if let Some(inner) = t.strip_prefix("Net[").and_then(|s| s.strip_suffix(']')) {
1276                        effects.insert("Net");
1277                        for cn in inner.split(',') {
1278                            let cn = cn.trim();
1279                            if cn.is_empty() {
1280                                continue;
1281                            }
1282                            if cn == "*" {
1283                                net_star = true;
1284                            } else if crate::NET_DEST_CLASSES.contains(&cn) {
1285                                net_classes.insert(cn.to_string());
1286                            } else {
1287                                // ⟨0.24⟩ A POLICY ERROR, not a warning — SPEC §6.2 `be0b9a9`. Byte-identical
1288                                // in shape to the reason-class arm below, and byte-identical in harm:
1289                                // MEASURED `deny Net[known-telemetry,unknown-hosst]` → exit 0 where the
1290                                // correctly-spelled rule exits 1. The typo is dropped, the filter NARROWS
1291                                // to `[known-telemetry]`, and the gate stops covering unidentifiable
1292                                // destinations while the operator reads a gate that looks armed.
1293                                not_honoured!(
1294                                    true,
1295                                    PolicyError::KIND_NET_CLASS,
1296                                    cn,
1297                                    ["known-telemetry", "known-partner", "unknown-host", "*"],
1298                                    line,
1299                                    format!(
1300                                        "unrecognised Net destination-class `{cn}` in `{line}` — \
1301                                         accepted: known-telemetry, known-partner, unknown-host, plus `*`"
1302                                    )
1303                                );
1304                            }
1305                        }
1306                        continue;
1307                    }
1308                    // `Unknown[dispatch,reflect]` / `Unknown[*]` / `Unknown[dynamic]`: the reason-scoped form.
1309                    if let Some(inner) = t.strip_prefix("Unknown[").and_then(|s| s.strip_suffix(']')) {
1310                        effects.insert(UNKNOWN);
1311                        for cn in inner.split(',') {
1312                            let cn = cn.trim();
1313                            if cn.is_empty() {
1314                                continue;
1315                            }
1316                            if cn == "*" {
1317                                unknown_star = true;
1318                            } else if cn == "dynamic" {
1319                                unknown_classes.extend(ReasonClass::dynamic_set());
1320                            } else if let Some(rc) = ReasonClass::from_token(cn) {
1321                                unknown_classes.insert(rc);
1322                            } else if let Some(a) = aliases.get(cn) {
1323                                unknown_classes.extend(a.iter().copied()); // ⟨0.19⟩ config `unknown-alias`
1324                                // ⟨0.24⟩ → the verdict names it AND what it expanded to (SPEC §3.1
1325                                // `7f5b5ba`): the NAME alone cannot tell a reader which gate ran.
1326                                out.used_aliases
1327                                    .insert(cn.to_string(), a.iter().map(|c| c.token().to_string()).collect());
1328                            } else {
1329                                // ⟨0.24⟩ A POLICY ERROR, not a warning — see `ParsedPolicy::errors`. The
1330                                // token is still dropped below so `rules` stays well-formed for the
1331                                // advisory readers (`unverified`, `parsepolicy`); the gate routes refuse
1332                                // on `errors` before any of it is used as a verdict.
1333                                not_honoured!(
1334                                    true,
1335                                    PolicyError::KIND_REASON_CLASS,
1336                                    cn,
1337                                    [
1338                                        "reflect",
1339                                        "dispatch",
1340                                        "indirect",
1341                                        "native",
1342                                        "unresolved",
1343                                        "setup",
1344                                        "dynamic",
1345                                        "*"
1346                                    ],
1347                                    line,
1348                                    format!(
1349                                        "unrecognised reason-class/alias `{cn}` in `{line}` — accepted: \
1350                                         reflect, dispatch, indirect, native, unresolved, setup, plus the \
1351                                         aliases `dynamic` and `*`, plus any `unknown-alias` defined in \
1352                                         the `.candor/config` beside the policy. (⟨0.24⟩ an \
1353                                         `unknown-alias` whose OWN definition names an unrecognised class \
1354                                         is refused WHOLE, so a typo in the config surfaces as an \
1355                                         undefined alias here — check the `unknown-alias` lines too, and \
1356                                         the line above this one.)"
1357                                    )
1358                                );
1359                            }
1360                        }
1361                        continue;
1362                    }
1363                    let e = if t == UNKNOWN { Some(UNKNOWN) } else { cap_from_name(t) };
1364                    match e {
1365                        Some(e) => {
1366                            effects.insert(e);
1367                            if e == UNKNOWN {
1368                                unknown_star = true; // bare Unknown ⇒ all classes
1369                            }
1370                            if e == "Net" {
1371                                net_star = true; // bare Net ⇒ all destinations
1372                            }
1373                        }
1374                        None => {
1375                            scope = Some(t.to_string());
1376                            break;
1377                        }
1378                    }
1379                }
1380                if effects.is_empty() {
1381                    // The accepted set is the §1 effect vocabulary plus `Unknown` — SORTED, so the
1382                    // document is deterministic and diffable across engines.
1383                    let mut acc: Vec<&str> = candor_report::EFFECTS.to_vec();
1384                    acc.push(UNKNOWN);
1385                    acc.sort_unstable();
1386                    let msg = format!(
1387                        "`deny` names no known effect (accepted: {}): {line}",
1388                        acc.join(", ")
1389                    );
1390                    warn_ignore!("candor: policy error — {msg}");
1391                    // ⟨0.24⟩ FATAL (SPEC §6.2 `1e1748a`). MEASURED four-way: `deny Nett app` -> exit 0
1392                    // on all four; the rule is DELETED and the gate is green. `Nett` is read as the
1393                    // SCOPE (the first unrecognised token ends the effect list), so the line parses to
1394                    // a deny of NOTHING.
1395                    //
1396                    // **A `deny` whose effect list ends up EMPTY is malformed under EITHER reading** —
1397                    // typo-in-the-effect or scope-with-no-effect are both nonsense — so there is no
1398                    // legitimate policy it could be and refusing it loses nothing. What stays open is
1399                    // only the genuinely ambiguous middle (`deny Net Exex app`: at least one valid
1400                    // effect plus an unrecognised trailing token that MIGHT be a scope), which the
1401                    // parser cannot tell from a legitimate scope and which `parsepolicy` shows either
1402                    // way by dumping the `scope` it read.
1403                    not_honoured!(
1404                        true,
1405                        PolicyError::KIND_EFFECT_NAME,
1406                        scope.as_deref().unwrap_or(""),
1407                        acc,
1408                        line,
1409                        msg
1410                    );
1411                    continue;
1412                }
1413                // `*` (or bare `Unknown`) means all classes ⇒ empty filter (matches any Unknown).
1414                if unknown_star {
1415                    unknown_classes.clear();
1416                } else if !unknown_classes.is_empty() && !unknown_classes.contains(&ReasonClass::Unresolved) {
1417                    // A2 under-gating lint: a narrowed scope that omits `unresolved` (the catch-all for holes
1418                    // an engine couldn't classify) may silently tolerate exactly those — flag it (advisory).
1419                    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}");
1420                }
1421                // `*` (or bare `Net`) means all destinations ⇒ empty filter (matches any Net).
1422                if net_star {
1423                    net_classes.clear();
1424                }
1425                out.rules.push(PolicyRule { effects, scope, unknown_classes, net_classes, raw: line.to_string() });
1426            }
1427            "pure" => out.rules.push(PolicyRule {
1428                effects: BTreeSet::new(),
1429                scope: toks.next().map(str::to_string),
1430                unknown_classes: BTreeSet::new(),
1431                net_classes: BTreeSet::new(),
1432                raw: line.to_string(),
1433            }),
1434            "forbid" => {
1435                let a = toks.next().unwrap_or("");
1436                let arrow = toks.next().unwrap_or("");
1437                let b = toks.next().unwrap_or("");
1438                if a.is_empty() || arrow != "->" || b.is_empty() {
1439                    let msg = format!("`forbid` is malformed (want `forbid <scope> -> <scope>`): {line}");
1440                    warn_ignore!("candor: ignoring layering rule ({msg})");
1441                    // The token reported is whatever sat in the ARROW position — `->` must be its own
1442                    // token, so `forbid glued->arrow` finds nothing there and that absence is the finding.
1443                    not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
1444                    continue;
1445                }
1446                out.layer_rules.push(LayerRule {
1447                    from: a.to_string(),
1448                    to: b.to_string(),
1449                    raw: line.to_string(),
1450                });
1451            }
1452            // ⟨0.29⟩ `only <A> -> <B> [<C> …]` — the PERMISSION form. Everything after the arrow is a
1453            // scope, so the rule takes a LIST where `forbid` takes one destination; that is the whole
1454            // ergonomic difference, and it is why the tail is not read left-to-right for anything else.
1455            "only" => {
1456                let a = toks.next().unwrap_or("");
1457                let arrow = toks.next().unwrap_or("");
1458                let to: Vec<String> = toks.map(str::to_string).collect();
1459                if a.is_empty() || arrow != "->" || to.is_empty() {
1460                    let msg = format!(
1461                        "`only` is malformed (want `only <scope> -> <scope> [<scope> …]`): {line}"
1462                    );
1463                    warn_ignore!("candor: ignoring permission rule ({msg})");
1464                    // Same witness as `forbid`: whatever sat in the ARROW position, since `->` must be
1465                    // its own token and `only glued->arrow` finds nothing there.
1466                    not_honoured!(false, PolicyError::KIND_RULE_KIND, arrow, ["->"], line, msg);
1467                    continue;
1468                }
1469                out.only_rules.push(OnlyRule {
1470                    from: a.to_string(),
1471                    to,
1472                    raw: line.to_string(),
1473                });
1474            }
1475            other => {
1476                let msg = format!(
1477                    "unknown rule kind `{other}` (accepted: allow, deny, forbid, only, pure): {line}"
1478                );
1479                warn_ignore!("candor: ignoring policy rule ({msg})");
1480                not_honoured!(
1481                    false,
1482                    PolicyError::KIND_RULE_KIND,
1483                    other,
1484                    ["allow", "deny", "forbid", "only", "pure"],
1485                    line,
1486                    msg
1487                );
1488            }
1489        }
1490    }
1491    resolve_accepts(&mut out, &pending_accepts, aliases, warn);
1492    out
1493}
1494
1495/// ⟨0.41⟩ TURN EACH HELD `accept` LINE INTO AN [`AcceptEntry`], A NON-FATAL IGNORED LINE, OR A FATAL POLICY ERROR
1496/// (SPEC §6.2 ⟨0.41⟩).
1497///
1498/// **THE CARDINAL DIRECTION HERE IS ACCEPTING A VIOLATION THAT IS NEW**, so every doubt leaves the row RED: a line
1499/// this function cannot read exactly is ignored (and listed in `ignored`), a key it cannot trust is refused, and
1500/// an unrecognised effect or class token is the policy error the unrecognised-token rule already makes it
1501/// (exit 2) — `Fss`, or `dispatchh` inside the brackets, exactly as `deny Nett app` is. A bug here may cost a
1502/// false red; it must not cost a false green.
1503///
1504/// Called AFTER the rule loop: the named rule may come later in the file, and "a `deny`, `pure` or `forbid` line
1505/// that PARSED as a rule" is only decidable once every line has been read.
1506fn resolve_accepts(
1507    out: &mut ParsedPolicy,
1508    pending: &[(usize, &str, &str)],
1509    aliases: &std::collections::BTreeMap<String, std::collections::BTreeSet<ReasonClass>>,
1510    warn: bool,
1511) {
1512    if pending.is_empty() {
1513        return;
1514    }
1515    let deny_keys: BTreeSet<String> = out.rules.iter().map(|r| rule_key(&r.raw)).collect();
1516    let forbid_keys: BTreeSet<String> = out.layer_rules.iter().map(|r| rule_key(&r.raw)).collect();
1517    let other_keys: BTreeSet<String> = out
1518        .allow_rules
1519        .iter()
1520        .map(|r| rule_key(&r.raw))
1521        .chain(out.only_rules.iter().map(|r| rule_key(&r.raw)))
1522        .collect();
1523    for &(line_no, text, line) in pending {
1524        let t: Vec<&str> = line.split(is_ascii_ws).filter(|s| !s.is_empty()).collect();
1525        // A line this function cannot read exactly: ignored with a warning, listed in `ignored`, and the violation it
1526        // meant to accept STAYS. NOT fatal — the rest of the policy means what it says.
1527        let ignore = |out: &mut ParsedPolicy, why: String| {
1528            let msg = format!("`accept` line not honoured — {why}: {line}");
1529            if warn {
1530                eprintln!("candor: ignoring policy rule ({msg})");
1531            }
1532            out.errors.push(PolicyError {
1533                kind: PolicyError::KIND_RULE_KIND,
1534                token: "accept".to_string(),
1535                accepted: Vec::new(),
1536                rule: line.to_string(),
1537                message: msg,
1538                line: line_no,
1539                text: text.to_string(),
1540                fatal: false,
1541            });
1542        };
1543        if t.len() < 2 {
1544            ignore(out, "it names no function (want `accept <fn> [<Effect>…] for <rule> because <why>`)".into());
1545            continue;
1546        }
1547        let func = t[1];
1548        let Some(for_at) = (2..t.len()).find(|&i| t[i] == "for") else {
1549            ignore(out, "it lacks the literal `for` between the effects and the rule it names".into());
1550            continue;
1551        };
1552        let bec_at = (for_at + 1..t.len()).find(|&i| t[i] == "because");
1553        let Some(bec_at) = bec_at.filter(|&b| b + 1 < t.len()) else {
1554            ignore(
1555                out,
1556                "it carries no reason — write `because <why>` (a `#` begins a comment, so `because #123` is \
1557                 empty: write `ticket-123`)"
1558                    .into(),
1559            );
1560            continue;
1561        };
1562        let key = t[for_at + 1..bec_at].join(" ");
1563        let because = t[bec_at + 1..].join(" ");
1564        // ---- the effect list --------------------------------------------------------------------------------
1565        let mut names: BTreeSet<&'static str> = BTreeSet::new();
1566        let mut classes: Option<BTreeSet<String>> = None;
1567        let mut malformed: Option<String> = None;
1568        let mut fatal = false;
1569        for &e in &t[2..for_at] {
1570            if e == UNKNOWN {
1571                malformed.get_or_insert("bare `Unknown` is not accepted here — write an explicit class list, `Unknown[dispatch]`".into());
1572            } else if let Some(inner) = e.strip_prefix("Unknown[").and_then(|x| x.strip_suffix(']')) {
1573                let mut any = false;
1574                for cn in inner.split(',') {
1575                    let cn = cn.trim();
1576                    if cn.is_empty() {
1577                        malformed.get_or_insert(format!("empty class in `{e}`"));
1578                    } else if cn == "*" || cn == "dynamic" || aliases.contains_key(cn) {
1579                        malformed.get_or_insert(format!(
1580                            "`{cn}` is an alias, not a reason class — list the classes by name"
1581                        ));
1582                    } else if let Some(rc) = ReasonClass::from_token(cn) {
1583                        classes.get_or_insert_with(BTreeSet::new).insert(rc.token().to_string());
1584                        any = true;
1585                    } else {
1586                        // The policy error the unrecognised-token rule already makes it: dropping the token would
1587                        // silently NARROW what the entry covers (or widen it, were it the only one).
1588                        let msg = format!(
1589                            "unrecognised reason-class/alias `{cn}` in `{line}` — an `accept` line lists classes \
1590                             by name: reflect, dispatch, indirect, native, unresolved, setup"
1591                        );
1592                        if warn {
1593                            eprintln!("candor: policy error — {msg}");
1594                        }
1595                        out.errors.push(PolicyError {
1596                            kind: PolicyError::KIND_REASON_CLASS,
1597                            token: cn.to_string(),
1598                            accepted: ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"]
1599                                .iter()
1600                                .map(|s| s.to_string())
1601                                .collect(),
1602                            rule: line.to_string(),
1603                            message: msg,
1604                            line: line_no,
1605                            text: text.to_string(),
1606                            fatal: true,
1607                        });
1608                        fatal = true;
1609                    }
1610                }
1611                if any {
1612                    names.insert(UNKNOWN);
1613                }
1614            } else if e.contains('[') {
1615                malformed.get_or_insert(format!("`{e}`: only `Unknown` takes a bracketed filter on an `accept` line"));
1616            } else if let Some(n) = cap_from_name(e) {
1617                names.insert(n);
1618            } else {
1619                let mut acc: Vec<&str> = candor_report::EFFECTS.to_vec();
1620                acc.sort_unstable();
1621                let msg = format!(
1622                    "unknown effect-name `{e}` in `{line}` (accepted: {})",
1623                    acc.join(", ")
1624                );
1625                if warn {
1626                    eprintln!("candor: policy error — {msg}");
1627                }
1628                out.errors.push(PolicyError {
1629                    kind: PolicyError::KIND_EFFECT_NAME,
1630                    token: e.to_string(),
1631                    accepted: acc.iter().map(|s| s.to_string()).collect(),
1632                    rule: line.to_string(),
1633                    message: msg,
1634                    line: line_no,
1635                    text: text.to_string(),
1636                    fatal: true,
1637                });
1638                fatal = true;
1639            }
1640        }
1641        if fatal {
1642            continue; // the whole policy is refused; nothing else to say about this line
1643        }
1644        if let Some(why) = malformed {
1645            ignore(out, why);
1646            continue;
1647        }
1648        // ---- the rule it names -------------------------------------------------------------------------------
1649        let is_deny = deny_keys.contains(&key);
1650        let is_forbid = forbid_keys.contains(&key);
1651        if other_keys.contains(&key) && !is_deny && !is_forbid {
1652            ignore(
1653                out,
1654                "it cannot name an `allow` or `only` rule — its verdict row does not say which value or scope was \
1655                 reached, so an entry would silently accept a NEW one"
1656                    .into(),
1657            );
1658            continue;
1659        }
1660        if !is_deny && !is_forbid {
1661            ignore(
1662                out,
1663                "it names no `deny`/`pure`/`forbid` line of this file — the tokens between `for` and `because` must \
1664                 equal a rule line's tokens, token for token"
1665                    .into(),
1666            );
1667            continue;
1668        }
1669        if is_deny && names.is_empty() {
1670            ignore(out, "an entry for a `deny`/`pure` rule must list at least one effect".into());
1671            continue;
1672        }
1673        if is_forbid && !names.is_empty() {
1674            ignore(out, "an entry for a `forbid` rule takes no effects".into());
1675            continue;
1676        }
1677        if is_positional_key(func) {
1678            ignore(
1679                out,
1680                format!(
1681                    "`{func}` is a POSITIONAL key (numbered by the compiler): an unrelated edit renumbers it and the \
1682                     entry would accept DIFFERENT code. Accept the enclosing function instead — its entry also covers \
1683                     the closure it contains"
1684                ),
1685            );
1686            continue;
1687        }
1688        out.accepts.push(AcceptEntry {
1689            line: line_no,
1690            text: text.to_string(),
1691            func: func.to_string(),
1692            effects: names,
1693            classes,
1694            rule_key: key,
1695            forbid: is_forbid,
1696            because,
1697        });
1698    }
1699}
1700
1701#[cfg(test)]
1702mod tests {
1703    /// R443 — the note's bytes, pinned. Both gate routes render through this one function now, so the
1704    /// only thing a route may vary is the PREFIX; anything else drifting is this assertion failing.
1705    /// The strings are the ones candor-scan has printed since ⟨0.19⟩, character for character, because
1706    /// the point of the change was to give the SILENT route the EXISTING disclosure — not a new one.
1707    #[test]
1708    fn unverified_note_lines_render_one_note_for_both_routes() {
1709        let holes = vec![
1710            ("via_callback".to_string(), "deny Unknown via_callback".to_string()),
1711            ("domain::price".to_string(), "deny Net Unknown domain".to_string()),
1712        ];
1713        let scan = super::unverified_note_lines("candor-scan", &holes);
1714        let query = super::unverified_note_lines("candor-query gate", &holes);
1715        assert_eq!(scan.len(), 4, "header + one line per hole + the advisory footer");
1716        assert_eq!(
1717            scan[0],
1718            "candor-scan: note — 2 function(s) PASS the policy but are Unknown (purity NOT verified — the Unknown could hide a forbidden effect):"
1719        );
1720        assert_eq!(scan[1], "    `via_callback`  → add  `deny Unknown via_callback`");
1721        assert_eq!(scan[2], "    `domain::price`  → add  `deny Net Unknown domain`");
1722        assert_eq!(
1723            scan[3],
1724            "  (advisory; add the upgrade(s) to REQUIRE provable purity, or run `candor-query unverified` for detail — the gate verdict is unchanged)"
1725        );
1726        // The prefix, and NOTHING else, is what the two routes differ in.
1727        assert_eq!(scan[1..], query[1..]);
1728        assert_eq!(query[0], scan[0].replacen("candor-scan", "candor-query gate", 1));
1729        // A clean pass renders NO lines at all — the over-charge control, at the renderer.
1730        assert!(super::unverified_note_lines("candor-scan", &[]).is_empty());
1731    }
1732
1733    #[test]
1734    fn db_table_covering_is_strict() {
1735        use super::db_table_covered as c;
1736        assert!(c("ledger.entries", "Ledger.Entries")); // case-insensitive exact
1737        assert!(c("ledger.*", "ledger.entries"));       // schema wildcard
1738        assert!(!c("ledger.*", "ledgerx.entries"));     // boundary-respecting
1739        assert!(!c("entries", "ledger.entries"));       // no silent qualification widening
1740        assert!(c("entries", "entries"));
1741    }
1742
1743    #[test]
1744    fn allow_db_parses_and_gates() {
1745        let p = super::parse_policy("allow Db in billing  ledger.* customers\n");
1746        assert_eq!(p.allow_rules.len(), 1);
1747        assert_eq!(p.allow_rules[0].effect, "Db");
1748        assert!(super::literal_allowed("Db", "ledger.entries", &p.allow_rules[0].literals));
1749        assert!(super::literal_allowed("Db", "customers", &p.allow_rules[0].literals));
1750        assert!(!super::literal_allowed("Db", "audit.log", &p.allow_rules[0].literals));
1751    }
1752
1753    use super::*;
1754
1755    #[test]
1756    fn policy_parses() {
1757        let p = parse_policy(
1758            "# the domain layer must stay pure of I/O\n\
1759             deny Net Db  domain\n\
1760             deny Exec\n\
1761             pure  parse\n\
1762             nonsense line\n\
1763             deny notaneffect\n",
1764        );
1765        let rules = &p.rules;
1766        assert_eq!(rules.len(), 3);
1767        assert_eq!(rules[0].effects, ["Db", "Net"].into_iter().collect::<BTreeSet<_>>());
1768        assert_eq!(rules[0].scope.as_deref(), Some("domain"));
1769        assert!(rules[1].effects.contains("Exec") && rules[1].scope.is_none());
1770        assert!(rules[2].effects.is_empty() && rules[2].scope.as_deref() == Some("parse"));
1771        // sweep [16]: a classic-Mac (bare \r) multi-rule policy must NOT collapse to the first rule.
1772        let cr = parse_policy("deny Net a\rdeny Exec b\rdeny Db c\r");
1773        assert_eq!(cr.rules.len(), 3, "bare-CR lines must each parse");
1774        assert!(cr.rules.iter().any(|r| r.effects.contains("Exec") && r.scope.as_deref() == Some("b")));
1775        // mixed \r\n and bare \r normalize identically.
1776        assert_eq!(parse_policy("deny Net a\r\ndeny Exec b\r").rules.len(), 2);
1777        // SOUNDNESS R1224 — a U+FEFF at byte offset 0 is stripped, so the first rule is EVALUATED…
1778        let bom = parse_policy("\u{feff}deny Fs core\ndeny Net web\n");
1779        assert_eq!(bom.rules.len(), 2, "a byte-0 BOM must not cost the first rule: {:?}", bom.rules);
1780        assert!(bom.rules[0].effects.contains("Fs") && bom.rules[0].scope.as_deref() == Some("core"));
1781        assert!(bom.errors.is_empty(), "a byte-0 BOM is not a malformed line: {:?}", bom.errors.iter().map(|e| &e.message).collect::<Vec<_>>());
1782        assert_eq!(parse_policy("\u{feff}deny Fs core").rules.len(), 1, "a one-rule BOM'd policy is not zero-rule");
1783        // …with CRLF too (the mark is removed before line-splitting and CR normalisation).
1784        assert_eq!(parse_policy("\u{feff}deny Fs core\r\ndeny Net web\r\n").rules.len(), 2);
1785        // …and ANYWHERE ELSE it stays a token character: the line is still ignored (and reported).
1786        let mid = parse_policy("deny Net web\n\u{feff}deny Fs core\n");
1787        assert_eq!(mid.rules.len(), 1, "a BOM at the start of a LATER line is not an encoding mark");
1788        assert!(!mid.errors.is_empty(), "the BOM'd later line is reported, not silently dropped");
1789        assert_eq!(parse_policy("\u{feff}\u{feff}deny Fs core").rules.len(), 0, "only ONE mark is removed");
1790        assert_eq!(parse_policy("deny Fs\u{feff} core").rules.len(), 0, "a mid-token BOM stays a token character");
1791        // `Unknown` is a denyable token; a bare `deny` with no effect is ignored.
1792        assert_eq!(parse_policy("deny Unknown core").rules[0].effects, ["Unknown"].into_iter().collect());
1793        assert!(parse_policy("deny\ndeny   \n").rules.is_empty());
1794        // a `deny` whose first token is a non-effect names no effect -> dropped, NOT a pure rule.
1795        assert!(parse_policy("deny notaneffect scope").rules.is_empty());
1796        // the first non-effect token ENDS the rule: a later effect token is not collected.
1797        let p2 = parse_policy("deny Net foo Db");
1798        assert_eq!(p2.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1799        assert_eq!(p2.rules[0].scope.as_deref(), Some("foo"));
1800        // NBSP is NOT a token separator (only ASCII White_Space is) — pinned to MATCH Java, which
1801        // drops it: a `deny\u{a0}Net` is one token `deny\u{a0}Net`, NOT `deny` + `Net`, so it names no
1802        // known effect and is dropped. Splitting on Unicode whitespace here would let candor see a deny
1803        // the JVM engine doesn't — a gateless-divergence between impls. (See is_ascii_ws.)
1804        assert!(parse_policy("deny\u{a0}Net core").rules.is_empty(),
1805                "an NBSP between deny and the effect must NOT split into separate tokens");
1806        // The NBSP rides INTO the scope token rather than separating it: `deny Net\u{a0}domain` is
1807        // `deny` + `Net` + `\u{a0}domain` — Net is the effect, the scope keeps the NBSP verbatim.
1808        let nb = parse_policy("deny Net \u{a0}domain");
1809        assert_eq!(nb.rules.len(), 1);
1810        assert_eq!(nb.rules[0].effects, ["Net"].into_iter().collect::<BTreeSet<_>>());
1811        assert_eq!(nb.rules[0].scope.as_deref(), Some("\u{a0}domain"));
1812    }
1813
1814    #[test]
1815    fn reason_scoped_unknown_parses() {
1816        use super::ReasonClass::*;
1817        // `Unknown[dispatch,indirect]` narrows the Unknown membership to those classes.
1818        let p = parse_policy("deny Net Unknown[dispatch,indirect] dom\n");
1819        let r = &p.rules[0];
1820        assert!(r.effects.contains("Unknown") && r.effects.contains("Net"));
1821        assert_eq!(r.scope.as_deref(), Some("dom"));
1822        assert_eq!(r.unknown_classes, [Dispatch, Indirect].into_iter().collect());
1823        // bare `Unknown` and `Unknown[*]` ⇒ empty filter (all classes).
1824        assert!(parse_policy("deny Net Unknown dom\n").rules[0].unknown_classes.is_empty(), "bare Unknown ⇒ all");
1825        assert!(parse_policy("deny Net Unknown[*] dom\n").rules[0].unknown_classes.is_empty(), "Unknown[*] ⇒ all");
1826        // `dynamic` alias = every genuine class incl. unresolved, excl. setup.
1827        assert_eq!(
1828            parse_policy("deny Net Unknown[dynamic] dom\n").rules[0].unknown_classes,
1829            [Reflect, Dispatch, Indirect, Native, Unresolved].into_iter().collect()
1830        );
1831        // config `unknown-alias` (⟨0.19⟩): a user-defined name resolves; a reserved name is rejected.
1832        let aliases = super::parse_unknown_aliases(
1833            "unknown-alias risky = reflect,native\nunknown-alias telemetry = indirect\nunknown-alias reflect = native\n");
1834        assert_eq!(aliases.get("risky"), Some(&[Reflect, Native].into_iter().collect()));
1835        assert_eq!(aliases.get("telemetry"), Some(&[Indirect].into_iter().collect()));
1836        assert!(!aliases.contains_key("reflect"), "a config alias may not shadow a class token");
1837        // the `unknown-alias` KEY matches case-insensitively (parity with java/ts/swift, which lowercase it)
1838        assert_eq!(super::parse_unknown_aliases("Unknown-Alias hot = native\n").get("hot"),
1839                   Some(&[Native].into_iter().collect()), "the unknown-alias key must match case-insensitively");
1840        let pr = super::parse_policy_with_aliases("deny Net Unknown[risky] api\n", &aliases);
1841        assert_eq!(pr.rules[0].unknown_classes, [Reflect, Native].into_iter().collect());
1842        // an UNDEFINED alias name is dropped-with-warning → empty filter (behaves like bare Unknown[*])
1843        assert!(super::parse_policy_with_aliases("deny Net Unknown[nope] api\n", &aliases).rules[0].unknown_classes.is_empty());
1844        // classify: raw reason tokens → normative classes (mirrors java ReasonClass.classify).
1845        assert_eq!(ReasonClass::classify("reflect:Class.forName"), Reflect);
1846        assert_eq!(ReasonClass::classify("native:extern fn"), Native);
1847        assert_eq!(ReasonClass::classify("callback:unresolved call"), Indirect);
1848        assert_eq!(ReasonClass::classify("ambiguous:same-name local defs"), Dispatch);
1849        assert_eq!(ReasonClass::classify("unresolved"), Unresolved);
1850        assert_eq!(ReasonClass::classify("whatever-new"), Unresolved); // conservative catch-all
1851    }
1852
1853    /// THE CONTROL SPEC §4 ⟨0.24⟩ MAKES A SHOULD: a FABRICATED, off-vocabulary kind must still behave as
1854    /// §2 forward-compatibility requires. Without it, "added a fifth kind" and "stopped checking the kind
1855    /// set" are the same diff — the classifier is one `_ =>` arm away from either.
1856    ///
1857    /// SOUNDNESS R490 — THE TYPED HALF NOW EXISTS (`Kind`), AND THIS IS THE CONTROL ITS OWN DOC COMMENT
1858    /// SAID TO ADD AT THE SAME COMMIT. The JVM engine's failure was a string classifier correct on
1859    /// `ambiguous` since July beside a typed `Kind` enum that lacked the kind entirely — one token
1860    /// classified two ways inside one engine, concealed precisely because the string half was right. That
1861    /// is unreachable here only while the two halves are the SAME table, so the round-trip below asserts
1862    /// it for every kind rather than for the ones someone remembered: `Kind::ALL` is what `classify`
1863    /// matches on, and what this loop iterates.
1864    #[test]
1865    fn off_vocabulary_kinds_round_trip_and_classify_through_the_catch_all() {
1866        use ReasonClass::*;
1867        // A kind no engine emits and no section names. §2: tolerated, and classified CONSERVATIVELY —
1868        // `unresolved`, the catch-all, so a narrowed `Unknown[unresolved]`/`[dynamic]`/`[*]` still bites it.
1869        assert_eq!(ReasonClass::classify("banana:whatever"), Unresolved);
1870        assert_eq!(ReasonClass::classify("banana:dispatch of a banana"), Unresolved,
1871                   "a canonical kind appearing in the DETAIL must not leak into the classification");
1872        // …and it must not be swallowed into a narrower class. These are the four wrong answers.
1873        for wrong in [Reflect, Dispatch, Indirect, Native] {
1874            assert_ne!(ReasonClass::classify("banana:whatever"), wrong);
1875        }
1876        // The five §4 ⟨0.24⟩ kinds all classify, and `ambiguous` is the fifth — pinned beside the
1877        // fabricated one deliberately: one arm chain answers both, so a change that stops distinguishing
1878        // them fails here rather than in the field.
1879        assert_eq!(ReasonClass::classify("reflect:x"), Reflect);
1880        assert_eq!(ReasonClass::classify("native:x"), Native);
1881        assert_eq!(ReasonClass::classify("dispatch:Owner.member"), Dispatch);
1882        assert_eq!(ReasonClass::classify("callback:x"), Indirect);
1883        assert_eq!(ReasonClass::classify("ambiguous:x"), Dispatch);
1884        // ⟨0.24⟩ `dep:<hash>` / `dep-stale:<pkg>` are REGISTERED §4 kinds, not migration ones — swift
1885        // emits them per dependency ENTRY, and this engine CONSUMES swift/ts reports through
1886        // candor-query. §6.2 pins their class as `unresolved`, which is where the catch-all lands them;
1887        // pinned so a future prefix arm cannot move them without saying so.
1888        assert_eq!(ReasonClass::classify("dep:9f2c1a"), Unresolved);
1889        assert_eq!(ReasonClass::classify("dep-stale:somepkg"), Unresolved);
1890
1891        // SOUNDNESS R1218 — every SIBLING-PRODUCER alias is a PREFIX (§6.2's table header), so each must
1892        // project identically bare and with the `:detail` its producer actually writes. The alias arm that
1893        // was an equality test failed exactly here: swift writes `dynamicMemberLookup:Bag.anything`.
1894        for (bare, class) in [
1895            ("dynamicMemberLookup", Reflect),
1896            ("closure", Indirect),
1897            ("task-handoff", Indirect),
1898            ("indy", Dispatch),
1899            ("missing-config", Setup),
1900            ("no-tsconfig", Setup),
1901            ("no-node_modules", Setup),
1902        ] {
1903            assert_eq!(ReasonClass::classify(bare), class, "bare `{bare}`");
1904            let detailed = format!("{bare}:Owner.member");
1905            assert_eq!(ReasonClass::classify(&detailed), class, "`{detailed}` must project like `{bare}`");
1906        }
1907
1908        // ── SOUNDNESS R490: THE TYPED HALF AND THE STRING HALF ARE ONE TABLE ────────────────────────
1909        // Every kind this engine can WRITE must classify to the class it declares, through the same
1910        // `classify` a consumer runs — asserted over `Kind::ALL`, so a kind added without a class, or a
1911        // class arm changed without the producer, fails here. A hand-listed version of this loop is the
1912        // bug it is guarding against.
1913        assert_eq!(Kind::ALL.len(), 5, "SPEC §4 ⟨0.7⟩'s kind set is CLOSED at five");
1914        for k in Kind::ALL {
1915            assert_eq!(ReasonClass::classify(&k.reason("some detail")), k.class(),
1916                       "kind {:?} spells a reason its own classifier does not agree with", k);
1917            assert!(!k.token().contains(':'), "a kind token cannot contain the `kind:detail` separator");
1918        }
1919        // The one NORMATIVE detail shape (§4): `dispatch:<owner-type>.<member>`, dotted, which is what
1920        // §3.1's dispatch frontier parses. Pinned as a STRING because that is what reaches the report.
1921        assert_eq!(Kind::dispatch_on("Store", "put"), "dispatch:Store.put");
1922        assert_eq!(ReasonClass::classify(&Kind::dispatch_on("Store", "put")), Dispatch);
1923        // …and the reserved DOT-FREE form stays a `dispatch:` (⟨0.24⟩): it is the "no owner could be
1924        // formed at all" spelling, free text, not conformance-compared — but still class `dispatch`, so
1925        // re-kinding it to `callback:` would silently narrow every `deny E Unknown[dispatch]` in the field.
1926        assert_eq!(ReasonClass::classify(&Kind::Dispatch.reason("untyped cross-package receiver")), Dispatch);
1927    }
1928
1929    #[test]
1930    fn net_destination_class_parses_and_classifies() {
1931        // `Net[unknown-host,known-telemetry]` narrows the Net membership to those destination classes.
1932        let p = parse_policy("deny Net[unknown-host,known-telemetry] dom\n");
1933        let r = &p.rules[0];
1934        assert!(r.effects.contains("Net"));
1935        assert_eq!(r.scope.as_deref(), Some("dom"));
1936        assert_eq!(
1937            r.net_classes,
1938            ["unknown-host", "known-telemetry"].iter().map(|s| s.to_string()).collect()
1939        );
1940        // bare `Net` and `Net[*]` ⇒ empty filter (all destinations).
1941        assert!(parse_policy("deny Net dom\n").rules[0].net_classes.is_empty(), "bare Net ⇒ all");
1942        assert!(parse_policy("deny Net[*] dom\n").rules[0].net_classes.is_empty(), "Net[*] ⇒ all");
1943        // an unknown destination-class is dropped-with-warning → empty filter (behaves like bare Net[*]).
1944        assert!(parse_policy("deny Net[nope] dom\n").rules[0].net_classes.is_empty());
1945        // the classifier: telemetry (subdomain-aware), model host, unresolved, and the config-partner path.
1946        let no_partners = BTreeSet::new();
1947        assert_eq!(crate::net_dest_class("sentry.io", &no_partners), "known-telemetry");
1948        assert_eq!(crate::net_dest_class("us.i.posthog.com", &no_partners), "known-telemetry"); // 0.20.1 corpus-grown
1949        assert_eq!(crate::net_dest_class("o1.ingest.sentry.io", &no_partners), "known-telemetry");
1950        assert_eq!(crate::net_dest_class("api.openai.com", &no_partners), "known-partner", "a model host is known-partner");
1951        assert_eq!(crate::net_dest_class("evil.example.com", &no_partners), "unknown-host");
1952        let partners: BTreeSet<String> = ["api.stripe.com".to_string()].into_iter().collect();
1953        assert_eq!(crate::net_dest_class("api.stripe.com", &partners), "known-partner", "config-declared partner");
1954        assert_eq!(crate::net_dest_class("api.stripe.com", &no_partners), "unknown-host", "partner is config-only");
1955        // `net-partner` config parsing: host-normalized, case-insensitive key, multi-value.
1956        let pset = super::parse_net_partners("net-partner Api.Stripe.com:443\nNET-PARTNER hooks.stripe.com\n");
1957        assert!(pset.contains("api.stripe.com") && pset.contains("hooks.stripe.com"));
1958    }
1959
1960    #[test]
1961    fn allowlist_parses() {
1962        let p = parse_policy(
1963            "allow Net in billing  api.stripe.com  hooks.stripe.com\n\
1964             allow Exec in ci  git\n\
1965             allow Fs in config  /etc/app\n\
1966             allow Net  github.com\n\
1967             allow Clock  whatever\n\
1968             allow Net in nohosts\n\
1969             allow\n",
1970        );
1971        assert_eq!(p.allow_rules.len(), 4); // Clock carries no literal surface — rejected; Db now does
1972        assert_eq!((p.allow_rules[0].effect, p.allow_rules[0].scope.as_deref()), ("Net", Some("billing")));
1973        assert_eq!(
1974            p.allow_rules[0].literals,
1975            ["api.stripe.com", "hooks.stripe.com"].iter().map(|s| s.to_string()).collect()
1976        );
1977        assert_eq!((p.allow_rules[1].effect, p.allow_rules[1].scope.as_deref()), ("Exec", Some("ci")));
1978        assert!(p.allow_rules[1].literals.contains("git"));
1979        assert_eq!((p.allow_rules[2].effect, p.allow_rules[2].scope.as_deref()), ("Fs", Some("config")));
1980        assert_eq!((p.allow_rules[3].effect, p.allow_rules[3].scope.is_none()), ("Net", true));
1981
1982        let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
1983        assert!(literal_allowed("Net", "api.stripe.com:443", &set(&["api.stripe.com"])));
1984        // IPv6: a bare literal is matched WHOLE (no first-colon collapse), so a different address in the
1985        // same block is NOT accepted; a bracketed `[host]:port` matches the bare host. (/code-review.)
1986        assert!(literal_allowed("Net", "2001:db8::aa", &set(&["2001:db8::aa"])));
1987        assert!(!literal_allowed("Net", "2001:db8::ff", &set(&["2001:db8::aa"])));
1988        assert!(!literal_allowed("Net", "2001:dead::1", &set(&["2001:db8::aa"])));
1989        assert!(literal_allowed("Net", "[2001:db8::aa]:443", &set(&["2001:db8::aa"])));
1990        assert_eq!(host_part("2001:db8::aa"), "2001:db8::aa");
1991        assert_eq!(host_part("[2001:db8::aa]:443"), "2001:db8::aa");
1992        assert_eq!(host_part("api.stripe.com:443"), "api.stripe.com");
1993        assert!(literal_allowed("Exec", "/usr/bin/git", &set(&["git"])));
1994        assert!(!literal_allowed("Exec", "/usr/bin/curl", &set(&["git"])));
1995        assert!(literal_allowed("Fs", "/etc/app/conf.toml", &set(&["/etc/app"])));
1996        assert!(!literal_allowed("Fs", "/etc/shadow", &set(&["/etc/app"])));
1997        assert_eq!(cmd_base("/usr/bin/git"), "git");
1998    }
1999
2000    #[test]
2001    fn layering_rule_parses() {
2002        let p = parse_policy(
2003            "forbid domain -> infra\n\
2004             forbid  app::web  ->  app::db \n\
2005             forbid domain infra\n\
2006             forbid domain ->\n\
2007             forbid\n",
2008        );
2009        assert_eq!(p.layer_rules.len(), 2);
2010        assert_eq!((p.layer_rules[0].from.as_str(), p.layer_rules[0].to.as_str()), ("domain", "infra"));
2011        assert_eq!((p.layer_rules[1].from.as_str(), p.layer_rules[1].to.as_str()), ("app::web", "app::db"));
2012    }
2013
2014    /// ⟨0.29⟩ `only <A> -> <B> [<C> …]` — the PERMISSION form. The tail is a LIST, which is the whole
2015    /// ergonomic difference from `forbid`; a missing arrow or an EMPTY tail is dropped rather than read
2016    /// as "A may reach nothing", a rule far likelier to be a typo than an intention.
2017    #[test]
2018    fn parses_the_only_permission_form_and_drops_the_malformed() {
2019        let p = parse_policy(
2020            "only model -> util\n\
2021             only  app::web  ->  app::db  app::dto \n\
2022             only model util\n\
2023             only model ->\n\
2024             only\n",
2025        );
2026        assert_eq!(p.only_rules.len(), 2, "the two well-formed lines, and only those");
2027        assert_eq!(p.only_rules[0].from.as_str(), "model");
2028        assert_eq!(p.only_rules[0].to, vec!["util".to_string()]);
2029        assert_eq!(p.only_rules[1].from.as_str(), "app::web");
2030        assert_eq!(p.only_rules[1].to, vec!["app::db".to_string(), "app::dto".to_string()],
2031                   "every token after the arrow is a permitted scope");
2032        // …and an `only` line is a RULE for the zero-rule refusal: a policy holding one is ARMED, so a
2033        // route that counted only deny/allow/forbid would call this file empty and refuse a live gate.
2034        assert!(!p.rules.is_empty() || !p.only_rules.is_empty(),
2035                "an only-only policy must not read as a zero-rule file");
2036    }
2037
2038    #[test]
2039    fn scope_matches_by_segment_not_substring() {
2040        assert!(scope_matches("app::domain::handle", "domain"));
2041        assert!(scope_matches("domain::handle", "domain"));
2042        assert!(scope_matches("app::domain", "domain"));
2043        assert!(scope_matches("crate::domain_logic", "domain"));
2044        assert!(!scope_matches("app::subdomain::handle", "domain"));
2045        assert!(!scope_matches("app::not_my_domain::f", "domain"));
2046        // multi-segment: intermediates exact, last is a prefix, contiguous.
2047        assert!(scope_matches("crate::net::client::send", "net::client"));
2048        assert!(scope_matches("crate::net::client_pool::get", "net::client"));
2049        assert!(!scope_matches("crate::net::server::send", "net::client"));
2050        assert!(!scope_matches("crate::network::client::send", "net::client"));
2051        assert!(!scope_matches("crate::net::x::client", "net::client"));
2052        assert!(!scope_matches("net", "net::client"));
2053        // DOTTED names (JVM/Swift/TS reports `candor-query` consumes): a scope must match across `.` too,
2054        // else a scoped deny/pure rule is silently inert → whatif false-green (gate-evasion). Both a
2055        // `.`-written and a `::`-written scope must match a dotted name.
2056        assert!(scope_matches("com.acme.domain.Pricing.quote", "domain"));
2057        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme.domain"));
2058        assert!(scope_matches("com.acme.domain.Pricing.quote", "acme::domain"));
2059        assert!(scope_matches("com.acme.infra.Net.fetch", "infra.Net"));
2060        assert!(!scope_matches("com.acme.subdomain.h", "domain"));
2061        assert!(!scope_matches("com.acme.domain.h", "infra"));
2062    }
2063
2064    #[test]
2065    fn fs_path_covered_respects_boundaries() {
2066        assert!(fs_path_covered("/etc/app", "/etc/app"));
2067        assert!(fs_path_covered("/etc/app", "/etc/app/cfg.toml"));
2068        assert!(fs_path_covered("/etc/app/", "/etc/app/cfg"));
2069        assert!(!fs_path_covered("/etc/app", "/etc/apppwned"));
2070        assert!(!fs_path_covered("/etc/app", "/etc/application/x"));
2071        assert!(!fs_path_covered("/etc/app/cfg", "/etc/app"));
2072        assert!(!fs_path_covered("/etc/app", "/etc/app/../passwd"));
2073        assert!(fs_path_covered("/", "/etc/app/x"));
2074        assert!(!fs_path_covered("etc/app", "/etc/app/cfg"));
2075        assert!(!fs_path_covered("/etc/app", "etc/app/cfg"));
2076        assert!(fs_path_covered("etc/app", "etc/app/cfg"));
2077    }
2078
2079    /// ⟨0.24⟩ THE PROVABLE-PURITY DISCLOSURE MUST ASK THE GATE WHAT "PASSES" MEANS — SPEC §6.2, and both
2080    /// directions in ONE test, because killing an over-charge is exactly where a silent under-report gets
2081    /// introduced and the fixture proving the fabrication is closed cannot show the reach closed with it.
2082    ///
2083    /// A hole is a function that PASSES its rule while `Unknown`. `unverified_hole_rule` used to compute
2084    /// PASSES from `r.effects` alone — the pre-⟨0.19⟩ question, asked after two rungs gave rules a
2085    /// NARROWING FILTER — so a rule the gate TOLERATES was read here as violated and the hole was DELETED
2086    /// from the disclosure. MEASURED 2026-07-28: `deny Unknown[reflect]` over an `indirect` hole → gate
2087    /// exit 0, `unverified` "every function in a pure/deny layer is PROVABLY clean ✓".
2088    ///
2089    /// ROW 1 (the fix) — the filter does NOT match, so the gate tolerates and this IS a hole.
2090    /// ROW 2 (the mirror) — the SAME rule, SAME function, filter spelled to MATCH: the gate fires, so it
2091    /// is a violation and NOT a hole. Without row 2 the fix is satisfied by a predicate that calls
2092    /// everything a hole, which is the mirror over-report of the thing being fixed.
2093    /// ROW 3 — the ⟨0.20⟩ `Net[dest…]` filter, the same shape on the other narrowing axis.
2094    /// ROW 4 — no filter at all: byte-identical to pre-⟨0.24⟩, which is what keeps conformance PARTs
2095    /// 12c/12d (four-way) from moving.
2096    #[test]
2097    fn a_narrowed_rule_the_gate_tolerates_is_a_hole_and_the_one_it_fires_on_is_not() {
2098        let effs = ["Unknown"];
2099        let indirect: BTreeSet<String> = ["indirect".to_string()].into_iter().collect();
2100        let hole = |src: &str, classes: Option<&BTreeSet<String>>, nets: &[String]| -> Option<String> {
2101            let p = parse_policy(src);
2102            unverified_hole_rule("app::go", &effs, classes, nets, &p.rules).map(|r| rule_and_upgrade(r).1)
2103        };
2104
2105        // ROW 1 — tolerated by the gate (indirect ∉ {reflect}) ⇒ a hole, and the upgrade WIDENS the
2106        // filter rather than appending a second `Unknown`.
2107        assert_eq!(
2108            hole("deny Unknown[reflect]\n", Some(&indirect), &[]).as_deref(),
2109            Some("deny Unknown"),
2110            "a rule the gate TOLERATES leaves the function unproven — that is the disclosure's whole subject"
2111        );
2112
2113        // ROW 2 — THE MIRROR. Same rule, same signature, filter spelled to match: the gate FIRES, so this
2114        // is a violation and the disclosure must stay silent about it.
2115        assert_eq!(
2116            hole("deny Unknown[indirect]\n", Some(&indirect), &[]),
2117            None,
2118            "a rule the gate FIRES on is a violation, not a hole — filter-awareness must not start \
2119             disclosing the gate's own findings back as unproven passes"
2120        );
2121        assert_eq!(hole("deny Unknown[dynamic]\n", Some(&indirect), &[]), None, "`dynamic` covers indirect");
2122
2123        // ROW 3 — the ⟨0.20⟩ destination filter, both ways, on a fn carrying Net BESIDE its Unknown.
2124        let netfn = ["Net".to_string(), "Unknown".to_string()];
2125        let telemetry = vec!["known-telemetry".to_string()];
2126        let p = parse_policy("deny Net[unknown-host]\n");
2127        assert_eq!(
2128            unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules)
2129                .map(|r| rule_and_upgrade(r).1)
2130                .as_deref(),
2131            Some("deny Net[unknown-host] Unknown"),
2132            "a `Net[dest…]` the fn's destinations do not match is tolerated, so the Unknown beside it is a hole"
2133        );
2134        let p = parse_policy("deny Net[known-telemetry]\n");
2135        assert_eq!(
2136            unverified_hole_rule("app::go", &netfn, Some(&indirect), &telemetry, &p.rules).map(|r| r.raw.clone()),
2137            None,
2138            "MIRROR: the matching destination filter FIRES, so it is a violation and not a hole"
2139        );
2140
2141        // ROW 4 — UNFILTERED, unchanged. `deny Unknown` fires on every Unknown (never a hole); `pure` and
2142        // `deny Fs` pass a fn with no real effect (always a hole) — the forms PARTs 12c/12d pin four-way.
2143        assert_eq!(hole("deny Unknown\n", Some(&indirect), &[]), None);
2144        assert_eq!(hole("pure\n", Some(&indirect), &[]).as_deref(), Some("deny Unknown"));
2145        assert_eq!(hole("deny Net Db  domain\ndeny Fs\n", Some(&indirect), &[]).as_deref(), Some("deny Fs Unknown"));
2146        assert_eq!(
2147            hole("deny Net Db  go\n", Some(&indirect), &[]).as_deref(),
2148            Some("deny Db Net Unknown go"),
2149            "the sorted multi-effect upgrade PART 12c-deny pins in all four engines"
2150        );
2151
2152        // A WITHHELD filter — no class set to read — counts as PASSING, so the hole is disclosed rather
2153        // than dropped. The gate withholds there too; between an advisory note that speaks and one that
2154        // goes quiet over a rule that never ran, only the first stays true.
2155        assert_eq!(hole("deny Unknown[reflect]\n", None, &[]).as_deref(), Some("deny Unknown"));
2156    }
2157}
2158
2159// ── ⟨0.27⟩ SPEC §3.4 `engine` — the engine↔baseline coupling ─────────────────────────────────────
2160/// What an `engine` pin says about the build that is running. Data, not a print-and-exit, so every
2161/// branch is testable — including the two that MUST NOT change the exit code.
2162#[derive(Debug, PartialEq, Eq)]
2163pub enum PinVerdict {
2164    /// No pin, or a pin qualified for another implementation. Today's behaviour, exactly.
2165    Absent,
2166    Match,
2167    /// A different version — the engine↔baseline coupling is broken. Exit 2 (UNEVALUABLE, never 1).
2168    Mismatch,
2169    /// Present but unreadable (`engine latest`, a bare `engine`, trailing junk). Exit 2: a pin that
2170    /// cannot be read is a guard the operator believes is on. This is the one place §6.2's
2171    /// warn-and-skip posture INVERTS — skipping a key that ADDS something costs that key; skipping a
2172    /// PIN costs the guard.
2173    Malformed,
2174    /// Well-formed, and this build cannot state its own release. UNANSWERABLE — §3.1's rule applies:
2175    /// disclosed, never scored, INCLUDING as satisfied.
2176    Undetermined,
2177}
2178
2179/// The pin that applies to `impl_name` — the qualified form wins over the unqualified one, and the
2180/// LAST occurrence wins (matching candor-java's map semantics). Two lines that DISAGREE about the same
2181/// key return a value that cannot parse, so they surface as [`PinVerdict::Malformed`] rather than one
2182/// silently discarding the other: two lines disagreeing about which engine to run is not a preference
2183/// to resolve, it is a question the config leaves unanswered.
2184pub fn engine_pin_for(text: &str, impl_name: &str) -> Option<String> {
2185    const IMPLS: [&str; 5] = ["java", "rust", "ts", "swift", "agents"];
2186    let (mut wild, mut qual): (Option<String>, Option<String>) = (None, None);
2187    let mut bad = false;
2188    for raw in text.lines() {
2189        let line = raw.split('#').next().unwrap_or("").trim();
2190        if line.is_empty() {
2191            continue;
2192        }
2193        let mut it = line.split_whitespace();
2194        if !it.next().is_some_and(|k| k.eq_ignore_ascii_case("engine")) {
2195            continue;
2196        }
2197        let rest: Vec<&str> = it.collect();
2198        let slot = |cur: &mut Option<String>, v: String| {
2199            if cur.as_ref().is_some_and(|p| *p != v) {
2200                *cur = Some(format!("{} / {v}", cur.as_ref().unwrap()));
2201            } else {
2202                *cur = Some(v);
2203            }
2204        };
2205        // A KNOWN QUALIFIER DECIDES OWNERSHIP BEFORE ARITY. Checking the one-token case first made `engine swift` a WILDCARD pin whose version is the literal "swift" -> MALFORMED -> exit 2 in every engine, so one operator forgetting a version on a qualified line killed the whole family. SPEC 3.4 says the skip is whole-line 'whatever follows it' -- and nothing following it is a case of that too.
2206        if let Some(head) = rest.first() {
2207            if IMPLS.contains(&head.to_ascii_lowercase().as_str()) {
2208                if head.eq_ignore_ascii_case(impl_name) {
2209                    if rest.len() == 2 { slot(&mut qual, rest[1].to_string()); } else { bad = true; }
2210                }
2211                continue;                                     // another impl's line, whatever follows it
2212            }
2213        }
2214        match rest.len() {
2215            0 => bad = true,                                  // a bare `engine` line
2216            1 => slot(&mut wild, rest[0].to_string()),        // engine <version>
2217            _ => bad = true,                                  // trailing junk / unknown qualifier
2218        }
2219    }
2220    if bad {
2221        return Some("<unreadable>".to_string());
2222    }
2223    // AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qua
2224    // lified value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java ex
2225    // ited 2 — the exact mirror of the bug just fixed in java, four engines the other way. Unreadability is a property of the LINE; precedence only decides which VERSION applies.
2226    if let Some(w) = &wild {
2227        if normalize_version(w).is_none() { return Some(w.clone()); }
2228    }
2229    qual.or(wild)
2230}
2231
2232/// [`PinVerdict`] for `pin` against `running`. Pure: no printing, no exit.
2233pub fn pin_verdict(pin: Option<&str>, running: &str) -> PinVerdict {
2234    let Some(pin) = pin else { return PinVerdict::Absent };
2235    let Some(want) = normalize_version(pin) else { return PinVerdict::Malformed };
2236    if running.trim().is_empty() || running == "unknown" {
2237        return PinVerdict::Undetermined;
2238    }
2239    if want == normalize_version(running).unwrap_or_else(|| running.trim().to_string()) {
2240        PinVerdict::Match
2241    } else {
2242        PinVerdict::Mismatch
2243    }
2244}
2245
2246/// A pin token → its comparable form, or None when it is not a version at all. A leading `v` is
2247/// optional (the GitHub-tag `v0.27.0` and the crate `0.27.0` are the same pin) and a two-part `0.27`
2248/// means `0.27.0`. Anything else — `latest`, a branch name — is MALFORMED rather than a version that
2249/// can never match: the difference decides whether the operator reads "wrong version" or "that is not
2250/// a version".
2251fn normalize_version(raw: &str) -> Option<String> {
2252    let s = raw.trim().strip_prefix(['v', 'V']).unwrap_or_else(|| raw.trim());
2253    let parts: Vec<&str> = s.split('.').collect();
2254    if !(parts.len() == 2 || parts.len() == 3) || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {
2255        return None;
2256    }
2257    Some(if parts.len() == 2 { format!("{s}.0") } else { s.to_string() })
2258}