Skip to main content

candor_classify/
accept.rs

1//! ⟨0.41⟩ `accept` — A KNOWN VIOLATION, WRITTEN DOWN WHERE A REVIEWER READS IT (SPEC §6.2 ⟨0.41⟩).
2//!
3//! **THE CARDINAL DIRECTION HERE IS ACCEPTING A VIOLATION THAT IS NEW.** Every decision below is made so that
4//! doubt leaves the row RED: an entry names ONE producing rule LINE, ONE exact `fn` and the effects it covers;
5//! a twin it cannot tell apart is accepted by nothing; an `Unknown` whose classes are not determinable is not
6//! covered by any filter; a nested unit is accepted only on evidence read from THIS run. A bug in this file may
7//! cost a false red; it must not cost a false green.
8//!
9//! **THE SEAM.** [`settle`] is a pure function of `(entries, rows, run facts)`. It is deliberately NOT inside
10//! [`crate::gate::gate`]: the scan route gates a workspace one MEMBER at a time and concatenates the rows, so a
11//! match made inside `gate()` would call an entry written for member A stale in member B's call, and could not
12//! see a twin that lives in a different member. Both routes call `gate()` (which only stamps each policy row
13//! with its producing line), then hand the rows of the WHOLE run here — one implementation, so the two routes
14//! cannot disagree about an acceptance over the same rows (§3.1 byte-equality).
15
16use crate::policy::{AcceptEntry, UNKNOWN};
17use candor_report::{AcceptedItem, GateViolation};
18use std::collections::{BTreeMap, BTreeSet};
19
20/// What the CALLER knows about the run that a list of rows cannot say.
21pub struct RunFacts<'a> {
22    /// EVERY unit of the run (not just the violating ones), as `(name a policy matches and a row carries,
23    /// identity)`. Two units sharing a name with different identities are `(hash, fn)` twins (§3.1 ⟨0.40⟩), and an
24    /// entry on that name accepts NEITHER. The identity is only ever compared for equality.
25    pub units: &'a [(String, String)],
26    /// [`crate::policy::rule_key`] of every rule line this route could NOT evaluate (§3.1 `unevaluated`): `forbid`
27    /// and `only` on `gate --report`, an unanswerable filter, a withheld pair. An entry on such a rule is `unjudged`
28    /// — nothing can be said either way — and is never stale.
29    pub unevaluated: &'a BTreeSet<String>,
30}
31
32/// What [`settle`] decided.
33#[derive(Default, Debug)]
34pub struct Settled {
35    /// Every input row that was NOT accepted, in input order. Includes rows no `accept` line could touch.
36    pub remaining: Vec<GateViolation>,
37    /// One `AS-EFF-012` row per STALE entry (`ok: false`, exit 1). Kept apart from `remaining` so a PROVISIONAL
38    /// per-member call (scan route) can ignore them: staleness is a property of the whole run.
39    pub stale: Vec<GateViolation>,
40    /// The verdict's `accepted` list, sorted by `line`, then `fn`, then `hash` (code point).
41    pub accepted: Vec<AcceptedItem>,
42    /// How many ROWS were taken out of `violations` (for the human channel's count).
43    pub accepted_rows: usize,
44    /// One human sentence per entry that is `unjudged`, `ambiguous`, `exceeded` — each carrying a remedy.
45    /// Unprefixed: the caller prefixes its own binary name.
46    pub notes: Vec<String>,
47    /// ⟨0.41⟩ `accepts` (the verb that proposes a refreshed policy): for every entry that accepted at least one row
48    /// DIRECTLY, `(its line, those rows)`. The verb narrows an entry to the union of these, so it reads the rows
49    /// from HERE and not from a second matcher. Nested rows are not in it (they ride the enclosing entry).
50    pub direct_rows: Vec<(usize, Vec<GateViolation>)>,
51    /// ⟨0.41⟩ the input line of every entry that produced an `AS-EFF-012` row in `stale`, in entry order — the verb
52    /// deletes exactly these lines.
53    pub stale_lines: Vec<usize>,
54}
55
56const DENY: &str = "AS-EFF-006";
57const FORBID: &str = "AS-EFF-009";
58
59/// The unit a positional unit is lexically nested in, when its name says so: `f::{closure#0}` is inside `f`,
60/// `f::{closure#0}::{closure#1}` inside `f::{closure#0}`. Read off the NAME, and only ever used together with the
61/// two conditions the clause pins (the enclosing unit's own row is accepted by the same entry in this run, and
62/// the nested row's effects are a subset), so it trusts no engine's attribution model.
63fn enclosing_of(func: &str) -> Option<&str> {
64    if !func.ends_with('}') {
65        return None;
66    }
67    let at = func.rfind("::{")?;
68    let seg = &func[at + 2..];
69    crate::policy::is_positional_key(seg).then(|| &func[..at])
70}
71
72fn covers(e: &AcceptEntry, row: &GateViolation) -> bool {
73    for x in &row.effects {
74        if x == UNKNOWN {
75            // Covered only by an `Unknown[F]` whose class list contains EVERY class of the row's `reasonClass`
76            // (all classes on the function). A row whose classes are not determinable is covered by NO filter.
77            let Some(cs) = &e.classes else { return false };
78            if !e.effects.contains(UNKNOWN) || row.reason_class.is_empty() {
79                return false;
80            }
81            if !row.reason_class.iter().all(|c| cs.contains(c)) {
82                return false;
83            }
84        } else if !e.effects.contains(x.as_str()) {
85            return false;
86        }
87    }
88    true
89}
90
91fn item(e: &AcceptEntry, rule: &str, func: &str, hash: &str, effects: Vec<String>, status: &str, enclosing: &str) -> AcceptedItem {
92    AcceptedItem {
93        rule: rule.to_string(),
94        func: func.to_string(),
95        hash: hash.to_string(),
96        effects,
97        line: e.line,
98        policy_rule: e.rule_key.clone(),
99        because: e.because.clone(),
100        status: status.to_string(),
101        enclosing: enclosing.to_string(),
102    }
103}
104
105/// The names two or more units of the run share with DIFFERENT identities — `(hash, fn)` twins (§3.1 ⟨0.40⟩). An entry on
106/// such a name accepts neither, and the `accepts` verb writes no line for one: one definition for both.
107pub fn twin_names(units: &[(String, String)]) -> BTreeSet<String> {
108    let mut by_name: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
109    for (n, id) in units {
110        by_name.entry(n.as_str()).or_default().insert(id.as_str());
111    }
112    by_name.into_iter().filter(|(_, s)| s.len() > 1).map(|(n, _)| n.to_string()).collect()
113}
114
115/// Decide a whole run's rows against its `accept` entries. `rows` is every violation of the run (policy rows carry
116/// their producing line in [`GateViolation::rule_key`]; every other row passes through untouched).
117pub fn settle(entries: &[AcceptEntry], rows: Vec<GateViolation>, facts: &RunFacts) -> Settled {
118    let mut out = Settled::default();
119    if entries.is_empty() {
120        out.remaining = rows;
121        return out;
122    }
123    // (hash, fn) TWINS: >1 distinct identity under one name.
124    let twins = twin_names(facts.units);
125    let twin = |name: &str| twins.contains(name);
126    let usable = |e: &AcceptEntry| !facts.unevaluated.contains(&e.rule_key) && !twin(&e.func);
127    let policy_row = |r: &GateViolation| !r.rule_key.is_empty() && (r.rule == DENY || r.rule == FORBID);
128
129    let mut acc = vec![false; rows.len()];
130    // per entry (by index): the rows it accepted directly; (nested row, enclosing row) pairs.
131    let mut direct: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
132    let mut nested: Vec<Vec<(usize, usize)>> = vec![Vec::new(); entries.len()];
133
134    // ---- direct: the entry names this row's producing LINE and this row's exact `fn`, and covers its effects.
135    // Per producing line, BEFORE any de-duplication of identical `{rule, fn, effects}` rows: a row two lines
136    // produce is two rows here, each accepted only by an entry naming ITS line.
137    for (i, r) in rows.iter().enumerate() {
138        if !policy_row(r) {
139            continue;
140        }
141        for (k, e) in entries.iter().enumerate() {
142            if usable(e) && e.rule_key == r.rule_key && e.func == r.func && covers(e, r) {
143                acc[i] = true;
144                direct[k].push(i);
145            }
146        }
147    }
148    // ---- the nested-unit rule: a row on a unit V keyed positionally and lexically inside U is accepted by an
149    // entry on U iff that entry accepts U's own row under the SAME rule line in THIS run, and V's row effects
150    // are a subset of U's. Both conditions are read from this run; an engine that does not carry V's effect onto
151    // U leaves V red — fail-closed, and disclosed by the red row itself.
152    for (i, v) in rows.iter().enumerate() {
153        if acc[i] || !policy_row(v) {
154            continue;
155        }
156        let mut cur: &str = &v.func;
157        for _ in 0..8 {
158            let Some(enc) = enclosing_of(cur) else { break };
159            for (k, e) in entries.iter().enumerate() {
160                if !usable(e) || e.rule_key != v.rule_key || e.func != enc {
161                    continue;
162                }
163                for &u in &direct[k] {
164                    let ur = &rows[u];
165                    if ur.func == enc && ur.rule_key == v.rule_key && v.effects.iter().all(|x| ur.effects.contains(x)) {
166                        acc[i] = true;
167                        nested[k].push((i, u));
168                    }
169                }
170            }
171            if acc[i] {
172                break;
173            }
174            cur = enc; // a closure inside a closure: climb to the next positional parent
175        }
176    }
177    out.accepted_rows = acc.iter().filter(|a| **a).count();
178    let remaining_idx: Vec<usize> = (0..rows.len()).filter(|&i| !acc[i]).collect();
179
180    // ---- statuses, stale rows.
181    let mut items: Vec<AcceptedItem> = Vec::new();
182    for (k, e) in entries.iter().enumerate() {
183        let code = if e.forbid { FORBID } else { DENY };
184        let names: Vec<String> = e.effects.iter().map(|s| s.to_string()).collect();
185        if facts.unevaluated.contains(&e.rule_key) {
186            items.push(item(e, code, &e.func, "", names, "unjudged", ""));
187            out.notes.push(format!(
188                "policy line {} (`{}`) is UNJUDGED — its rule `{}` could not be evaluated on this route, so nothing was \
189                 accepted by it. Gate at scan time (`candor-scan <dir> --policy …`) to judge it.",
190                e.line, e.text.trim(), e.rule_key
191            ));
192            continue;
193        }
194        if twin(&e.func) {
195            items.push(item(e, code, &e.func, "", names, "ambiguous", ""));
196            out.notes.push(format!(
197                "policy line {} (`{}`) is AMBIGUOUS — two units in this run are named `{}` with different identities, \
198                 so it accepts NEITHER. Gate the modules separately (one policy file per separately-gated module) so \
199                 each name is one unit.",
200                e.line, e.text.trim(), e.func
201            ));
202            continue;
203        }
204        if !direct[k].is_empty() {
205            out.direct_rows.push((e.line, direct[k].iter().map(|&i| rows[i].clone()).collect()));
206            // The row this entry accepted. Several rows (identical duplicate lines) are picked deterministically,
207            // so the two routes — which accumulate in different orders — carry the same item.
208            let &first = direct[k].iter().min_by_key(|&&i| (rows[i].hash.clone(), rows[i].effects.clone())).unwrap();
209            let r = &rows[first];
210            let mut eff = r.effects.clone();
211            eff.sort();
212            items.push(item(e, &r.rule, &r.func, &r.hash, eff, "accepted", ""));
213            for &(vi, _) in &nested[k] {
214                let v = &rows[vi];
215                let mut eff = v.effects.clone();
216                eff.sort();
217                items.push(item(e, &v.rule, &v.func, &v.hash, eff, "accepted", &e.func));
218            }
219            continue;
220        }
221        let over = remaining_idx.iter().map(|&i| &rows[i]).find(|r| policy_row(r) && r.rule_key == e.rule_key && r.func == e.func);
222        if let Some(r) = over {
223            items.push(item(e, code, &e.func, "", names, "exceeded", ""));
224            out.notes.push(format!(
225                "policy line {} (`{}`) no longer covers `{}`: it now performs {{ {} }} under `{}`, more than the {{ {} }} \
226                 accepted. The violation stays red. Review the new behaviour; if it is intended, widen the entry to list \
227                 it, otherwise fix the code.",
228                e.line, e.text.trim(), e.func, r.effects.join(", "), e.rule_key, e.effects.iter().copied().collect::<Vec<_>>().join(", ")
229            ));
230            continue;
231        }
232        // STALE: its rule was evaluated, and it accepted nothing and is not exceeded.
233        out.stale_lines.push(e.line);
234        out.stale.push(GateViolation {
235            rule: "AS-EFF-012".into(),
236            func: e.func.clone(),
237            effects: names.clone(),
238            detail: format!(
239                "`{}` has a STALE acceptance — policy line {} (`{}`) matched no violation of `{}`. Delete line {}: left \
240                 in place it would accept whatever a later edit puts under that key",
241                e.func, e.line, e.text.trim(), e.rule_key, e.line
242            ),
243            ..Default::default()
244        });
245    }
246    items.sort_by(|a, b| (a.line, &a.func, &a.hash).cmp(&(b.line, &b.func, &b.hash)));
247    out.accepted = items;
248    out.remaining = rows.into_iter().enumerate().filter(|(i, _)| !acc[*i]).map(|(_, r)| r).collect();
249    out
250}
251
252/// The human-channel line SPEC §6.2 ⟨0.41⟩ requires on EVERY run with at least one accepted row (*"211 violation(s)
253/// accepted by policy; 0 new"*) — never only on request: a gate that says `ok` over 211 accepted violations without
254/// the number is the silence this directive exists to avoid. `new` is what is still in `violations`.
255pub fn announce(accepted_rows: usize, new: usize) -> Option<String> {
256    (accepted_rows > 0).then(|| format!("{accepted_rows} violation(s) accepted by policy; {new} new"))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::policy::parse_policy_silent;
263
264    fn row(fnn: &str, key: &str, eff: &[&str], hash: &str, rc: &[&str]) -> GateViolation {
265        GateViolation {
266            rule: if eff.is_empty() { FORBID } else { DENY }.into(),
267            func: fnn.into(),
268            hash: hash.into(),
269            effects: eff.iter().map(|s| s.to_string()).collect(),
270            reason_class: rc.iter().map(|s| s.to_string()).collect(),
271            rule_key: key.into(),
272            ..Default::default()
273        }
274    }
275    fn entries(pol: &str) -> Vec<AcceptEntry> {
276        parse_policy_silent(pol, &Default::default()).accepts
277    }
278    fn run<'a>(units: &'a [(String, String)], uneval: &'a BTreeSet<String>) -> RunFacts<'a> {
279        RunFacts { units, unevaluated: uneval }
280    }
281    fn u(n: &str, id: &str) -> (String, String) {
282        (n.to_string(), id.to_string())
283    }
284
285    #[test]
286    fn grammar_pins_every_malformed_form_to_ignored() {
287        let bad = [
288            "deny Fs dao\naccept dao.a Fs for deny Fs dao",                                // no because
289            "deny Fs dao\naccept dao.a Fs for deny Fs dao because",                        // empty reason
290            "deny Fs dao\naccept dao.a Fs for deny Fs dao because #123",                   // comment ate it
291            "deny Fs dao\naccept dao.a Fs deny Fs dao because x",                          // no `for`
292            "deny Fs dao\naccept dao.a for deny Fs dao because x",                         // deny without effects
293            "deny Fs dao\naccept dao.a Fs for deny Fs app because x",                      // names no rule
294            "deny Fs dao\naccept dao.a Net[unknown-host] for deny Fs dao because x",       // bracket on non-Unknown
295            "deny Unknown dao\naccept dao.a Unknown for deny Unknown dao because x",       // bare Unknown
296            "deny Unknown dao\naccept dao.a Unknown[*] for deny Unknown dao because x",
297            "deny Unknown dao\naccept dao.a Unknown[dynamic] for deny Unknown dao because x",
298            // `{closure#N}` is UNWRITABLE here — the `#` begins a comment, so the line is cut before `for` and is
299            // ignored for that reason (§6.2: "which §6.2's comment rule makes unwritable anyway"). This engine has no
300            // writable positional spelling; `is_positional_key` is exercised directly below and by the nested-unit rule.
301            "deny Fs dao\naccept dao.f::{closure#0} Fs for deny Fs dao because x",
302            "deny Fs dao\nallow Net in dao ok.example\naccept dao.a Net for allow Net in dao ok.example because x",
303            "forbid dao -> infra\naccept dao.a Fs for forbid dao -> infra because x",       // forbid takes none
304            "deny Fs dao\naccept dao.a Fs for deny Fs dao extra because x",                // not the raw tokens
305            "deny Fs dao extra\naccept dao.a Fs for deny Fs dao because x",                // consumed tokens only
306        ];
307        for pol in bad {
308            let p = parse_policy_silent(pol, &Default::default());
309            assert!(p.accepts.is_empty(), "must NOT become an entry: {pol:?}");
310            assert!(p.errors.iter().any(|e| e.token == "accept" && !e.fatal), "must be listed as ignored: {pol:?}");
311        }
312        // a typo'd effect or class token is the policy ERROR, exactly like `deny Nett app`
313        for pol in [
314            "deny Fs dao\naccept dao.a Fss for deny Fs dao because x",
315            "deny Unknown dao\naccept dao.a Unknown[dispatchh] for deny Unknown dao because x",
316        ] {
317            let p = parse_policy_silent(pol, &Default::default());
318            assert!(p.accepts.is_empty());
319            assert!(!p.fatal_messages().is_empty(), "must be fatal: {pol:?}");
320        }
321    }
322
323    #[test]
324    fn grammar_reads_the_good_forms() {
325        let p = parse_policy_silent(
326            "accept dao.a Fs for deny   Fs\tdao extra because known since 2026 # why\n\
327             deny Fs dao extra\n\
328             pure parse\n\
329             forbid dao -> infra\n\
330             accept parse.x Clock Rand for pure parse because x\n\
331             accept dao.b for forbid dao -> infra because y\n\
332             deny Unknown[dispatch,native] dao\n\
333             accept dao.c Unknown[dispatch] for deny Unknown[dispatch,native] dao because z\n",
334            &Default::default(),
335        );
336        assert!(p.errors.is_empty(), "{:?}", p.errors.iter().map(|e| &e.message).collect::<Vec<_>>());
337        assert_eq!(p.accepts.len(), 4);
338        // an entry may name a rule that comes AFTER it, and the key is the RAW tokens single-spaced
339        assert_eq!(p.accepts[0].rule_key, "deny Fs dao extra");
340        assert_eq!(p.accepts[0].because, "known since 2026");
341        assert_eq!(p.accepts[0].line, 1);
342        assert!(p.accepts[2].forbid && p.accepts[2].effects.is_empty());
343        assert_eq!(p.accepts[3].classes, Some(["dispatch".to_string()].into_iter().collect()));
344        // directives, not rules
345        assert_eq!(p.rules.len(), 3);
346    }
347
348    #[test]
349    fn only_accept_lines_is_still_zero_rules() {
350        let p = parse_policy_silent("accept dao.a Fs for deny Fs dao because x\n", &Default::default());
351        assert!(p.rules.is_empty() && p.allow_rules.is_empty() && p.layer_rules.is_empty() && p.only_rules.is_empty());
352    }
353
354    #[test]
355    fn exact_fn_per_line_effect_subset() {
356        let es = entries("deny Fs dao\ndeny Fs\naccept dao.a Fs for deny Fs dao because x\n");
357        let units = [u("dao.a", "p#dao.a"), u("dao.ab", "p#dao.ab")];
358        let none = BTreeSet::new();
359        let rows = vec![
360            row("dao.a", "deny Fs dao", &["Fs"], "p#dao.a", &[]),
361            row("dao.a", "deny Fs", &["Fs"], "p#dao.a", &[]),            // the SECOND line's row: not named
362            row("dao.ab", "deny Fs dao", &["Fs"], "p#dao.ab", &[]),      // prefix: not named
363        ];
364        let s = settle(&es, rows, &run(&units, &none));
365        assert_eq!(s.accepted_rows, 1);
366        assert_eq!(s.remaining.len(), 2);
367        assert!(s.remaining.iter().all(|r| !(r.func == "dao.a" && r.rule_key == "deny Fs dao")));
368        assert!(s.stale.is_empty());
369        assert_eq!(s.accepted.len(), 1);
370        assert_eq!(s.accepted[0].status, "accepted");
371        // more effects than accepted: exceeded, row stays WHOLE, never also stale
372        let es = entries("deny Fs Rand dao\naccept dao.a Fs for deny Fs Rand dao because x\n");
373        let rows = vec![row("dao.a", "deny Fs Rand dao", &["Fs", "Rand"], "p#dao.a", &[])];
374        let s = settle(&es, rows, &run(&units, &none));
375        assert_eq!(s.accepted_rows, 0);
376        assert_eq!(s.remaining.len(), 1);
377        assert!(s.stale.is_empty());
378        assert_eq!(s.accepted[0].status, "exceeded");
379        assert!(s.notes[0].contains("widen the entry"));
380    }
381
382    #[test]
383    fn twins_unjudged_stale_and_unknown_classes() {
384        let none = BTreeSet::new();
385        // twins: same name, different identity -> NEITHER accepted
386        let es = entries("deny Fs dao\naccept dao.t Fs for deny Fs dao because x\n");
387        let units = [u("dao.t", "p#dao.t"), u("dao.t", "q#dao.t")];
388        let rows = vec![row("dao.t", "deny Fs dao", &["Fs"], "p#dao.t", &[]), row("dao.t", "deny Fs dao", &["Fs"], "q#dao.t", &[])];
389        let s = settle(&es, rows, &run(&units, &none));
390        assert_eq!((s.accepted_rows, s.remaining.len(), s.stale.len()), (0, 2, 0));
391        assert_eq!(s.accepted[0].status, "ambiguous");
392        // unevaluated rule: unjudged, never stale
393        let units = [u("dao.a", "p#dao.a")];
394        let uneval: BTreeSet<String> = ["deny Fs dao".to_string()].into_iter().collect();
395        let s = settle(&es_for("dao.a"), vec![], &run(&units, &uneval));
396        assert_eq!(s.accepted[0].status, "unjudged");
397        assert!(s.stale.is_empty());
398        // stale: rule evaluated, row gone
399        let s = settle(&es_for("dao.a"), vec![], &run(&units, &none));
400        assert_eq!(s.stale.len(), 1);
401        assert_eq!(s.stale[0].rule, "AS-EFF-012");
402        assert!(s.stale[0].detail.contains("Delete line 2"));
403        // Unknown: covered only by a filter containing EVERY class of the row; undeterminable classes covered by none
404        let es = entries("deny Unknown dao\naccept dao.c Unknown[dispatch] for deny Unknown dao because x\n");
405        let units = [u("dao.c", "p#dao.c")];
406        let ok = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch"])], &run(&units, &none));
407        assert_eq!(ok.accepted_rows, 1);
408        let moved = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &["dispatch", "native"])], &run(&units, &none));
409        assert_eq!((moved.accepted_rows, moved.remaining.len()), (0, 1));
410        assert_eq!(moved.accepted[0].status, "exceeded");
411        let blind = settle(&es, vec![row("dao.c", "deny Unknown dao", &["Unknown"], "p#dao.c", &[])], &run(&units, &none));
412        assert_eq!(blind.accepted_rows, 0, "an Unknown whose classes are not determinable is covered by NO filter");
413    }
414
415    fn es_for(f: &str) -> Vec<AcceptEntry> {
416        entries(&format!("deny Fs dao\naccept {f} Fs for deny Fs dao because x\n"))
417    }
418
419    #[test]
420    fn nested_units_need_the_enclosing_row_and_a_subset() {
421        let none = BTreeSet::new();
422        let es = es_for("dao::a");
423        let units = [u("dao::a", "p#dao::a"), u("dao::a::{closure#0}", "p#dao::a::{closure#0}")];
424        let a = row("dao::a", "deny Fs dao", &["Fs"], "p#dao::a", &[]);
425        let v = row("dao::a::{closure#0}", "deny Fs dao", &["Fs"], "p#dao::a::{closure#0}", &[]);
426        let s = settle(&es, vec![a.clone(), v.clone()], &run(&units, &none));
427        assert_eq!((s.accepted_rows, s.remaining.len()), (2, 0));
428        assert_eq!(s.accepted.len(), 2);
429        assert_eq!(s.accepted[1].enclosing, "dao::a");
430        // the nested row does MORE than the enclosing row -> stays red
431        let v2 = row("dao::a::{closure#0}", "deny Fs dao", &["Fs", "Clock"], "p#dao::a::{closure#0}", &[]);
432        let s = settle(&es, vec![a.clone(), v2], &run(&units, &none));
433        assert_eq!((s.accepted_rows, s.remaining.len()), (1, 1));
434        // the enclosing row is not accepted (engine did not carry the effect onto it) -> nested stays red
435        let s = settle(&es, vec![v], &run(&units, &none));
436        assert_eq!((s.accepted_rows, s.remaining.len()), (0, 1));
437        assert_eq!(s.stale.len(), 1, "nothing accepted and nothing exceeded");
438    }
439
440    #[test]
441    fn positional_spellings() {
442        for f in ["a::{closure#0}", "a::{closure#12}::{closure#3}", "x::{async_block#1}", "<T as U>::m::{constant#0}"] {
443            assert!(crate::policy::is_positional_key(f), "{f}");
444        }
445        for f in ["a::closure", "a::{closure}", "a::{closure#}", "a::{#0}", "dao::a", "a::b#1", "{closure#0"] {
446            assert!(!crate::policy::is_positional_key(f), "{f}");
447        }
448    }
449}