candor-ts 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/policy.mjs CHANGED
@@ -17,6 +17,121 @@ export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unr
17
17
  // the flag and the policy filter name the same vocabulary, so `--class dynamic` and `Unknown[dynamic]`
18
18
  // cannot drift into meaning different sets.
19
19
  export const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
20
+
21
+ // ⟨0.32⟩ THE UNIT IDENTITY, AS A PLUGGABLE POLICY — `units`, threaded through every function on this page
22
+ // that keys an accumulator by a function.
23
+ //
24
+ // It exists because a MULTI-REPORT gate must join by `hash` and never by bare `fn` (SPEC §2.2: "names may
25
+ // legitimately repeat across packages"), and this engine joined by `fn`. Measured on candor-ts at spec
26
+ // 0.31: `gate --report` over member `a` REFUSED a scoped rule at exit 2, and gating that same member
27
+ // beside an unrelated sibling exited 0 with `policy ✓` — a false green produced by ADDING a report. The
28
+ // harm is not effects merging (union can only add violations); it is REASONS merging, because a reason
29
+ // set is what makes an `Unknown` ANSWERABLE. `a`'s reasonless Unknown borrowed `b`'s `callback:` class,
30
+ // the filter saw a class the rule does not deny, and tolerated: "I cannot say" became "I checked".
31
+ //
32
+ // AND THE KEY SWAP CANNOT BE MADE ALONE, which is why the identity is a PARAMETER rather than a hardcoded
33
+ // `e.hash`. A policy SCOPE is written against the NAME (`deny Exec app::`) and the verdict ROW must print
34
+ // the NAME (§3.3.1 byte-equality with `scan --policy`), so a key that is not a name has to travel BESIDE
35
+ // the name, never instead of it. Keying by hash without that separation is a false green introduced while
36
+ // fixing a false green — the shape where killing an over-charge introduces a silent under-report. Hence
37
+ // the split landing: this identity is plumbed and verified a NO-OP first, and only then swapped.
38
+ //
39
+ // `identityUnits()` is the SCAN route's identity and the default everywhere: a scan gates ONE analysis
40
+ // world, its keys are already names, so that route cannot change behaviour by construction.
41
+ export const AMBIGUOUS = Symbol("candor:ambiguous-callee");
42
+
43
+ export function identityUnits() {
44
+ return {
45
+ key: (e) => e?.fn,
46
+ /** A callee/caller NAME resolved to a unit key, `AMBIGUOUS`, or null. Under identity a name IS a key. */
47
+ resolveCall: (_callerKey, name) => name,
48
+ /** Every unit key declaring `name`. Under identity, the name itself. */
49
+ unitsNamed: (name) => [name],
50
+ };
51
+ }
52
+
53
+ /** `units.key` with the identity default applied, for the sites that take `units` as an option. */
54
+ const unitKey = (units, e) => (units ? units.key(e) : e?.fn);
55
+
56
+ /**
57
+ * ⟨0.32⟩ THE MULTI-REPORT UNIT IDENTITY (SPEC §2.2) — for `gate --report` and every verb that reads a
58
+ * report PREFIX, which may match several sibling files (a workspace writes one report per member).
59
+ *
60
+ * THE KEY IS `hash` REFINED BY `fn`, NOT `hash` ALONE, and that is an engine-MEASURED deviation from
61
+ * candor-rust rather than a hedge. A rust `hash` is a DefPathHash, unique per unit; THIS engine emits
62
+ * `<package>#<local tail>` (scan.mjs), which is not unique — measured on hono's own sources, 13 hashes are
63
+ * shared by two or more DISTINCT `fn`s, with five different `handle` functions all keying `src#handle`.
64
+ * Keying by bare `hash` would MERGE those five into one unit and let each borrow the others' reason
65
+ * classes: the very defect this closes, reopened inside a single report. `hash` refined by `fn` is
66
+ * strictly FINER than either bare key, so it never joins two entries that bare-`fn` kept apart, and it
67
+ * never joins across packages by name — which is what §2.2's MUST is about. No `hash`: the name is the key.
68
+ *
69
+ * The key is a JSON PAIR rather than a joined string, so there is no separator that could occur inside
70
+ * either field and no two distinct (hash, fn) pairs can collapse into one key. A SYNTHETIC key (a
71
+ * callgraph name no entry declares, see `unitsNamed`) is the bare name, which is never JSON-pair shaped.
72
+ */
73
+ export function reportUnits(functions) {
74
+ const byName = new Map();
75
+ const pkgByKey = new Map();
76
+ const key = (e) => {
77
+ const h = typeof e?.hash === "string" ? e.hash : "";
78
+ return h ? JSON.stringify([h, String(e.fn)]) : String(e?.fn);
79
+ };
80
+ for (const f of functions ?? []) {
81
+ const k = key(f);
82
+ let s = byName.get(f.fn);
83
+ if (!s) { s = new Set(); byName.set(f.fn, s); }
84
+ s.add(k);
85
+ // The unit's PACKAGE, off the `hash` prefix and recorded here rather than parsed back out of the key
86
+ // — the key is an opaque identifier, and reading structure out of an identifier is how a rescue branch
87
+ // comes to depend on the shape a hash happens to have today.
88
+ const h = typeof f.hash === "string" ? f.hash : "";
89
+ const i = h.indexOf("#");
90
+ if (i > 0) pkgByKey.set(k, h.slice(0, i));
91
+ }
92
+ const pkgOf = (k) => pkgByKey.get(k) ?? "";
93
+ return {
94
+ key,
95
+ unitsNamed: (name) => {
96
+ const s = byName.get(name);
97
+ // A name NO entry declares is its own key. The §2.2 sidecar carries EVERY function while the report
98
+ // carries only the effectful ones, so an intermediate hop is routinely name-only — dropping it would
99
+ // break the chain that carries a callee's reason class up to its caller, and a LOST class is exactly
100
+ // how an unanswerable Unknown comes to look answerable.
101
+ return s ? [...s] : [name];
102
+ },
103
+ resolveCall: (callerKey, name) => {
104
+ const s = byName.get(name);
105
+ if (!s || s.size === 0) return name; // not an entry: its own key, as above
106
+ if (s.size === 1) return [...s][0];
107
+ // SAME PACKAGE FIRST, and only where it settles the question on its own: a member calling a name its
108
+ // OWN package declares means that one, which is the common case and unambiguous.
109
+ const mine = pkgOf(callerKey);
110
+ if (mine) {
111
+ const same = [...s].filter((k) => pkgOf(k) === mine);
112
+ if (same.length === 1) return same[0];
113
+ }
114
+ // Otherwise two or more units declare it and nothing here can say which is meant. Picking is what
115
+ // the bare-`fn` join did implicitly, and it is wrong both ways — right by luck when the guess lands,
116
+ // inventing a reach when it does not. The CALLER carries the ambiguity instead; see
117
+ // `resolveReasonClasses`, where dropping it silently was measured reopening this very defect.
118
+ return AMBIGUOUS;
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * ⟨0.32⟩ The effect set the GATE reads for one entry: the report's `inferred`, plus `Unknown` when the
125
+ * merge could not resolve one of this entry's callee names (see `reportUnits`). An unresolvable callee
126
+ * means the transitive set on the wire cannot be completed here, and `Unknown` is what this family says
127
+ * about a reach it cannot follow. Never subtracts.
128
+ */
129
+ export function effectiveInferred(f, reasonAcc, units = null) {
130
+ const inf = f?.inferred ?? [];
131
+ const amb = reasonAcc?.ambiguous;
132
+ if (!amb || amb.size === 0 || inf.includes("Unknown")) return inf;
133
+ return amb.has(unitKey(units, f)) ? [...inf, "Unknown"] : inf;
134
+ }
20
135
  /** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
21
136
  export function reasonClass(why) {
22
137
  const w = String(why).trim().toLowerCase();
@@ -49,8 +164,11 @@ export function reasonClass(why) {
49
164
  // function whose Unknown is purely INHERITED carries no reason of its own — 24% of Unknown-bearing entries
50
165
  // on this engine's sources and 57-58% on execa/got. Matching against the direct field reads a field that
51
166
  // answers a different question.
52
- export function resolveReasonClasses(functions, callgraph = {}) {
167
+ export function resolveReasonClasses(functions, callgraph = {}, units = null) {
168
+ const U = units ?? identityUnits();
53
169
  const acc = new Map();
170
+ // ⟨0.32⟩ the units whose OWN callee name the merge could not resolve — see the contribution below.
171
+ const ambiguous = new Set();
54
172
  // ⟨0.24⟩ THE REACH IS THE REPORT'S OWN, not the sidecar's. §2 embeds the call edges per entry (`calls`)
55
173
  // precisely so a consumer without the sidecar can reconstruct the graph, and rust (`reason_class_acc`),
56
174
  // java (`gateInputFromReport`: `edges…addAll(e.calls())`) and swift all resolve the reason classes over
@@ -69,9 +187,37 @@ export function resolveReasonClasses(functions, callgraph = {}) {
69
187
  if (!s) { s = new Set(); edges.set(caller, s); }
70
188
  s.add(callee);
71
189
  };
72
- for (const f of functions ?? []) for (const c of f.calls ?? []) addEdge(f.fn, c);
190
+ // ⟨0.32⟩ NODES AND EDGES. `calls` names a callee by BARE `fn`, so keying the accumulators by a unit key
191
+ // while joining the EDGES by name leaves the same defect one layer down — harder to see, because the
192
+ // node table looks right. Every edge endpoint therefore goes through `units.resolveCall`, which is
193
+ // identity on the scan route and the hash join on the report route.
194
+ const link = (callerKey, name) => {
195
+ const r = U.resolveCall(callerKey, name);
196
+ // AMBIGUOUS: two or more units declare this name and nothing here can say which is meant. Dropping
197
+ // the edge is right — picking would invent a reach — but dropping it SILENTLY is not, and that was
198
+ // measured on candor-rust: the caller lost the reason class it would have inherited, stayed
199
+ // ANSWERABLE through a reason of its own, and a RED verdict went GREEN by adding a report. So the
200
+ // ambiguity is CONTRIBUTED at the caller's entry instead (below), before the fixpoint.
201
+ if (r === AMBIGUOUS) { ambiguous.add(callerKey); return; }
202
+ if (r != null) addEdge(callerKey, r);
203
+ };
204
+ for (const f of functions ?? []) {
205
+ const ck = U.key(f);
206
+ for (const c of f.calls ?? []) link(ck, c);
207
+ }
73
208
  for (const [caller, callees] of Object.entries(callgraph ?? {})) {
74
- for (const c of Array.isArray(callees) ? callees : []) addEdge(caller, c);
209
+ // The SIDECAR's caller is a NAME too, and it takes the SAME rule rather than a carve-out: one unit
210
+ // declaring it resolves, several means the edge cannot be attributed and each candidate carries the
211
+ // ambiguity. Attaching it to all of them would be the name join again, in the caller direction —
212
+ // and that direction is the harmful one, since a borrowed class is what makes an Unknown answerable.
213
+ // A name no entry declares keeps a key of its own (`unitsNamed` returns it), so an intermediate hop
214
+ // the report omits as effect-free still carries classes through, exactly as it did before.
215
+ const ckeys = U.unitsNamed(caller);
216
+ if (ckeys.length === 1) {
217
+ for (const c of Array.isArray(callees) ? callees : []) link(ckeys[0], c);
218
+ } else if (ckeys.length > 1) {
219
+ for (const k of ckeys) ambiguous.add(k);
220
+ }
75
221
  }
76
222
  for (const f of functions ?? []) {
77
223
  const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
@@ -88,7 +234,17 @@ export function resolveReasonClasses(functions, callgraph = {}) {
88
234
  // direct Unknown it could not name (scan.mjs), which is the same rule one layer earlier, at the source.
89
235
  // It fires for a FOREIGN report (java/rust/swift/an older build), which every query verb also reads.
90
236
  if ((f.direct ?? []).includes("Unknown") && !(f.unknownWhy ?? []).length) cs.add("unresolved");
91
- if (cs.size) acc.set(f.fn, cs);
237
+ if (cs.size) acc.set(U.key(f), cs);
238
+ }
239
+ // ⟨0.32⟩ …and the AMBIGUITY CONTRIBUTES, exactly as the reasonless Unknown above it does — at the
240
+ // caller's own entry, before the fixpoint. `dispatch` is the right class by the vocabulary's own
241
+ // definition ("unresolved virtual/dynamic dispatch, SAME-NAME AMBIGUITY"), and it is evidence THIS
242
+ // merge holds — it saw two declarers — never a class borrowed from another function's body, so it
243
+ // cannot make some other function's Unknown answerable, which is what the original defect did.
244
+ for (const k of ambiguous) {
245
+ let s = acc.get(k);
246
+ if (!s) { s = new Set(); acc.set(k, s); }
247
+ s.add("dispatch");
92
248
  }
93
249
  // …then propagate over the call graph to a fixpoint, so `Unknown[reflect]` at a caller inheriting
94
250
  // Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
@@ -104,6 +260,10 @@ export function resolveReasonClasses(functions, callgraph = {}) {
104
260
  }
105
261
  }
106
262
  }
263
+ // ⟨0.32⟩ The unresolvable-callee set rides the accumulator NON-ENUMERABLY (the `zeroMatch` precedent
264
+ // below), so every existing caller keeps a plain `Map` and nothing that serializes one acquires a field
265
+ // the spec has not pinned. `effectiveInferred` is the only reader.
266
+ Object.defineProperty(acc, "ambiguous", { value: ambiguous, enumerable: false });
107
267
  return acc;
108
268
  }
109
269
 
@@ -495,6 +655,29 @@ export function refusalVerdict(spec, reason, unevaluated = null) {
495
655
  return o;
496
656
  }
497
657
 
658
+ /** ⟨0.32⟩ **THE VERDICT'S ROW ORDER — SPEC §2: "the sort key MUST include that identity".**
659
+ *
660
+ * The other half of the ⟨0.32⟩ identity clause, stated separately in the spec because carrying `hash`
661
+ * in the ROW without carrying it in the KEY is half a fix. `(rule, detail)` TIES on two units that
662
+ * share a name — `detail` is built from the NAME — so the pair's order was whatever each route happened
663
+ * to accumulate in, and §3.3.1 makes the document's ORDER part of the byte-equality between
664
+ * `scan --policy` and `gate --report`. This engine had NO sort at all on either route: order was the
665
+ * `functions`-array order crossed with policy-rule order, which agrees between the two routes only for
666
+ * as long as the report is written in the order the scan discovered.
667
+ *
668
+ * ONE definition, called from both assembly sites — two copies of a rule is how this family ended up
669
+ * with two element rules for the manifest reader. Compared with `<`/`>` and never `localeCompare`:
670
+ * ⟨0.24⟩ §3.3.1 requires every ordering to be locale-INDEPENDENT. `Array.prototype.sort` has been
671
+ * stable since ES2019, so rows with an equal key keep their arrival order.
672
+ */
673
+ export function sortViolations(violations) {
674
+ const c = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
675
+ return violations.sort((a, b) =>
676
+ c(a?.rule ?? "", b?.rule ?? "")
677
+ || c(a?.detail ?? "", b?.detail ?? "")
678
+ || c(a?.hash ?? "", b?.hash ?? ""));
679
+ }
680
+
498
681
  /** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
499
682
  * Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
500
683
  * policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
@@ -522,13 +705,26 @@ export function scopeMatchesPermitted(name, scope) {
522
705
  }
523
706
 
524
707
  export function scopeMatches(name, scope) {
708
+ // A TRAILING SEPARATOR MEANS EXACT SEGMENT — `app::` matches the segment `app` and nothing else,
709
+ // while bare `app` keeps the documented prefix behaviour and still matches `application_name`.
710
+ //
711
+ // REPORTED FROM THE FIELD (2026-08-23) and reproduced four-way: `forbid aws -> app` fired 14 times on
712
+ // honest AWS SDK calls, and writing `app::` did not help — the split drops empty parts, so `app::`
713
+ // became exactly ["app"] and the separator never survived to mean anything. The reporter deleted the
714
+ // rule, which is the real cost: the genuine violation it existed to catch will now never fire, and
715
+ // NOTHING in the policy file records that a boundary stopped being checked.
716
+ //
717
+ // ADDITIVE: bare `app` is unchanged, so no existing verdict moves. Only `app::` — which today
718
+ // silently behaves as `app` — starts meaning what everyone who writes it intends.
719
+ const exact = /(::|\.)\s*$/.test(scope);
525
720
  const segs = name.split(/[.:]+/).filter(Boolean);
526
721
  const parts = scope.split(/[.:]+/).filter(Boolean);
527
722
  if (parts.length === 0 || parts.length > segs.length) return false;
528
723
  const last = parts[parts.length - 1], init = parts.slice(0, -1);
529
724
  outer: for (let i = 0; i + parts.length <= segs.length; i++) {
530
725
  for (let k = 0; k < init.length; k++) if (segs[i + k] !== init[k]) continue outer;
531
- if (segs[i + parts.length - 1].startsWith(last)) return true;
726
+ const tail = segs[i + parts.length - 1];
727
+ if (exact ? tail === last : tail.startsWith(last)) return true;
532
728
  }
533
729
  return false;
534
730
  }
@@ -593,15 +789,18 @@ export function literalAllowed(effect, reached, values) {
593
789
  // entry (exit 2), so no gate DECISION rests on the empty set; what the mode additionally prevents is
594
790
  // the class list a BARE `deny Net` violation REPORTS being re-derived from `hosts` — asserting a
595
791
  // destination class the report never made, in a field a consumer reads as the producer's judgment.
596
- export function reportNetClasses(functions, { authoritative = false } = {}) {
792
+ export function reportNetClasses(functions, { authoritative = false, units = null } = {}) {
597
793
  const m = new Map();
598
794
  for (const f of functions ?? []) {
599
795
  const carried = Array.isArray(f.netClass) ? f.netClass : [];
600
796
  if (carried.length === 0 && !(authoritative && (f.inferred ?? []).includes("Net"))) continue;
601
- const prev = m.get(f.fn);
602
- // A repeated `fn` is malformed input; UNION rather than overwrite — the direction that cannot turn a
603
- // violation into a pass (java `gateInputFromReport` merges the same way).
604
- m.set(f.fn, prev ? [...new Set([...prev, ...carried])].sort() : carried);
797
+ // ⟨0.32⟩ keyed by the UNIT, not by the bare name — two members of a workspace may legitimately share
798
+ // one (SPEC §2.2), and merging their destination classes is the same borrowing the reason-class merge
799
+ // was measured doing. A repeated KEY is one unit reported twice; UNION rather than overwrite — the
800
+ // direction that cannot turn a violation into a pass (java `gateInputFromReport` merges the same way).
801
+ const k = unitKey(units, f);
802
+ const prev = m.get(k);
803
+ m.set(k, prev ? [...new Set([...prev, ...carried])].sort() : carried);
605
804
  }
606
805
  return m;
607
806
  }
@@ -730,11 +929,16 @@ export function wholePolicyUnanswerable(pol, verb = "this route") {
730
929
  };
731
930
  }
732
931
 
733
- export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
932
+ // ⟨0.32⟩ THE WITHHOLD IS KEYED BY UNIT; THE DISCLOSURE NAMES THE FUNCTION. Keying the held set by name
933
+ // would withhold the rule on EVERY same-named unit — and a withheld triple is a violation NOT reported,
934
+ // so that is the fail-OPEN direction: one member's missing evidence deleting another member's certain
935
+ // violation. `evaluatePolicy` and `narrowingContext` pass the unit key in turn. `why` still lists NAMES,
936
+ // because a key is not something an operator can look up in their own source.
937
+ export function unanswerableScoped(pol, functions, reasonAcc, netMap, units = null) {
734
938
  const held = new Set(), byRule = new Map();
735
- const key = (raw, fn, eff) => `${raw}\u0000${fn}\u0000${eff}`;
736
- const note = (r, fn, eff, why) => {
737
- held.add(key(r.raw, fn, eff));
939
+ const key = (raw, uk, eff) => `${raw}\u0000${uk}\u0000${eff}`;
940
+ const note = (r, uk, fn, eff, why) => {
941
+ held.add(key(r.raw, uk, eff));
738
942
  let e = byRule.get(r.raw);
739
943
  if (!e) { e = { rule: r.raw, fns: [], why }; byRule.set(r.raw, e); }
740
944
  e.fns.push(fn);
@@ -742,15 +946,16 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
742
946
  for (const r of pol.deny) {
743
947
  for (const f of functions) {
744
948
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
745
- const inf = f.inferred ?? [];
746
- if (r.netClasses?.length && inf.includes("Net") && !(netMap.get(f.fn)?.length))
747
- note(r, f.fn, "Net", "narrows on the Net DESTINATION CLASS, but %F carr%S Net with no "
949
+ const uk = unitKey(units, f);
950
+ const inf = effectiveInferred(f, reasonAcc, units);
951
+ if (r.netClasses?.length && inf.includes("Net") && !(netMap.get(uk)?.length))
952
+ note(r, uk, f.fn, "Net", "narrows on the Net DESTINATION CLASS, but %F carr%S Net with no "
748
953
  + "`netClass` in this report — the field the filter reads is absent, so the narrowing would "
749
954
  + "succeed for lack of evidence and drop a Net the bare `deny Net` catches. NOT EVALUATED for "
750
955
  + "those functions rather than passed: an absent optional field must not relax a fail-closed "
751
956
  + "gate. Use the bare `deny Net`, or gate at scan time (candor-ts <src> --policy <file>).");
752
- if (r.unknownClasses?.length && inf.includes("Unknown") && !(reasonAcc.get(f.fn)?.size))
753
- note(r, f.fn, "Unknown", "narrows on the Unknown REASON CLASS, but %F carr%S Unknown with no "
957
+ if (r.unknownClasses?.length && inf.includes("Unknown") && !(reasonAcc.get(uk)?.size))
958
+ note(r, uk, f.fn, "Unknown", "narrows on the Unknown REASON CLASS, but %F carr%S Unknown with no "
754
959
  + "reason reachable in this report — neither %P own `unknownWhy` nor a `calls` edge to one. "
755
960
  + "§6.2 resolves the class set TRANSITIVELY over the gate's reach; with the channel missing, "
756
961
  + "every narrowed filter silently tolerates while only the bare `deny Unknown` fires. NOT "
@@ -766,7 +971,7 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
766
971
  return { rule, why: `\`${rule}\` ` + why.replace("%F", list).replace("%S", names.length === 1 ? "ies" : "y")
767
972
  .replace("%P", names.length === 1 ? "its" : "their") };
768
973
  });
769
- return { unevaluated, withhold: held.size ? (r, fn, eff) => held.has(key(r.raw, fn, eff)) : null };
974
+ return { unevaluated, withhold: held.size ? (r, uk, eff) => held.has(key(r.raw, uk, eff)) : null };
770
975
  }
771
976
 
772
977
  /**
@@ -805,12 +1010,17 @@ export function unanswerableScoped(pol, functions, reasonAcc, netMap) {
805
1010
  /** ⟨0.24⟩ ONE definition of a function's Net destination classes for a run: the report's own field when it
806
1011
  * carries one (reportNetClasses), else the derivation from the surfaces the caller supplied. Exported so
807
1012
  * the ADVISORY verbs resolve the class the same way the gate does — see `classFilterExcludes`. */
808
- export function netClassResolver(incomplete = new Map(), partners = new Set(), netClasses = null) {
1013
+ export function netClassResolver(incomplete = new Map(), partners = new Set(), netClasses = null, units = null) {
809
1014
  // `has`, not `get(…) ?? derive`: an entry mapped to the EMPTY set is a report that carried no class, and
810
1015
  // on the authoritative route that absence is the answer — deriving one there would re-classify.
811
- return (f) => (netClasses && netClasses.has(f.fn))
812
- ? netClasses.get(f.fn)
813
- : netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
1016
+ // ⟨0.32⟩ `netClasses` is keyed by UNIT (reportNetClasses); `incomplete` stays keyed by NAME, because it
1017
+ // is the SCAN route's own live structure and that route's keys are names by construction.
1018
+ return (f) => {
1019
+ const k = unitKey(units, f);
1020
+ return (netClasses && netClasses.has(k))
1021
+ ? netClasses.get(k)
1022
+ : netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
1023
+ };
814
1024
  }
815
1025
 
816
1026
  /**
@@ -842,35 +1052,88 @@ export function netClassResolver(incomplete = new Map(), partners = new Set(), n
842
1052
  * An entry ABSENT from the report excludes NOTHING: a missing entry is missing evidence, and the direction
843
1053
  * that cannot turn a boundary crossing into a silent pass is "the rule still applies".
844
1054
  */
845
- export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf) {
1055
+ export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf, units = null) {
846
1056
  if (!entry) return false;
1057
+ // ⟨0.32⟩ the lookup key comes from the ENTRY, never from its name: a name-keyed lookup into a
1058
+ // unit-keyed map does not error, it returns `undefined`, and `undefined` reads here as "no classes" —
1059
+ // which `reasonClassesMatch` floors at `unresolved`. Silent, and in whichever direction the filter falls.
847
1060
  if (eff === "Unknown" && r.unknownClasses?.length)
848
- return !reasonClassesMatch(reasonAcc.get(entry.fn), r.unknownClasses);
1061
+ return !reasonClassesMatch(reasonAcc.get(unitKey(units, entry)), r.unknownClasses);
849
1062
  if (eff === "Net" && r.netClasses?.length)
850
1063
  return !netClassOf(entry).some((c) => r.netClasses.includes(c));
851
1064
  return false;
852
1065
  }
853
1066
 
854
- export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null) {
1067
+ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null, units = null, hashByName = null) {
855
1068
  const out = [];
856
1069
  // `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
857
1070
  const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
858
1071
  // §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
859
1072
  // (all destination classes on the fn) rides a Net violation. Both omitted when empty (byte-identical verdict).
860
- const push = (rule, fn, effects, detail, reasonClass, netClass) => {
861
- const rec = { rule, fn, effects, detail };
1073
+ //
1074
+ // ⟨0.32⟩ **AND `hash` — THE UNIT THIS ROW IS ABOUT (SPEC §2).** *"A verdict row MUST carry enough
1075
+ // identity for a consumer to tell two units apart… the sort key MUST include that identity."* MEASURED
1076
+ // on this engine 2026-08-24, `gate --report` over two reports whose members both define `go` and both
1077
+ // violate `deny Exec`: two BYTE-IDENTICAL rows, `{rule, fn, effects, detail}`, with nothing
1078
+ // attributing either to a package. A reader cannot tell two broken members from one listed twice, and
1079
+ // a consumer that fingerprints on name alone — candor's own SARIF action did — hides one finding
1080
+ // behind the other.
1081
+ //
1082
+ // `hash` AND NOT `package`/`loc`, because §2.2 already binds a consumer to join a verdict row back to
1083
+ // its report entry BY HASH; a row that omits it forces exactly the name join that clause forbids.
1084
+ // BESIDE `fn`, NEVER INSTEAD OF IT: the NAME is what a policy scope matches and what a human reads,
1085
+ // and swapping in the qualified form would silently stop every scoped rule matching — a false green
1086
+ // introduced by fixing a false green. Omitted when the producer has none to give (a hand-authored
1087
+ // report with no `hash`, which §3.1 says this route serves): absent is ⟨0.26⟩'s *cannot answer*,
1088
+ // never a fabricated id. Positioned directly after `fn`, so the row reads `{rule, fn, hash, …}` in
1089
+ // every engine.
1090
+ //
1091
+ // **AND IN THIS ENGINE THE IDENTITY IS THE PAIR, NOT `hash` ALONE — which is not a weaker port, it is
1092
+ // the same key `reportUnits` already uses.** A rust `hash` is a DefPathHash, unique per unit; THIS
1093
+ // engine emits `<package>#<local tail>`, and that is measurably not unique — on hono's own sources 13
1094
+ // hashes are shared by two or more distinct `fn`s, five `handle` functions all keying `src#handle`
1095
+ // (see `reportUnits`, which keys by `hash` REFINED BY `fn` for exactly that reason). So a row here
1096
+ // carries the module-qualified `fn` AND the package-qualified `hash`, and the pair is what tells two
1097
+ // units apart. Emitting `hash` alone would be the identity §2.2 forbids joining on.
1098
+ const push = (rule, fn, effects, detail, reasonClass, netClass, hash) => {
1099
+ const rec = { rule, fn };
1100
+ if (hash) rec.hash = hash;
1101
+ rec.effects = effects;
1102
+ rec.detail = detail;
862
1103
  if (reasonClass && reasonClass.length) rec.reasonClass = reasonClass;
863
1104
  if (netClass && netClass.length) rec.netClass = netClass;
864
1105
  out.push(rec);
865
1106
  };
1107
+ // ⟨0.32⟩ NAME -> unit identity, for the two rule codes that iterate the CALL GRAPH rather than the
1108
+ // entry list (AS-EFF-009/011) and so hold a name where the others hold the entry itself. Scan-route
1109
+ // only in practice — `gate --report` passes an EMPTY callgraph, because a report's `calls` is
1110
+ // effect-relevant while those rules match on NAME, so they are refused as unevaluable there — and a
1111
+ // scan gates ONE analysis world, in which a name identifies a unit.
1112
+ //
1113
+ // THE CALLER'S MAP WINS, and that is not a convenience — `forbid`'s violator is TYPICALLY PURE (a
1114
+ // `model.outer` that merely CALLS across a layer performs nothing), and a pure function has NO REPORT
1115
+ // ENTRY, so the fallback below cannot see it. MEASURED 2026-08-24: the AS-EFF-009 row for exactly that
1116
+ // shape came out with no `hash` here while candor-java and candor-swift both emitted one, because they
1117
+ // build the identity from the scan's own name table rather than from the entry list. scan.mjs passes
1118
+ // `unitHash` over `fns`, which holds every function including the pure ones. The fallback stays for the
1119
+ // callers that have no such table (the MCP/LSP routes and the unit tests): FIRST writer wins rather
1120
+ // than last, so its value is a function of the entry list and not of iteration order.
1121
+ const hashOf = hashByName ?? new Map();
1122
+ if (!hashByName)
1123
+ for (const f of functions)
1124
+ if (typeof f?.hash === "string" && f.hash && !hashOf.has(f.fn)) hashOf.set(f.fn, f.hash);
866
1125
  // Reason-scoped Unknown: the Unknown reason CLASS travels the call graph the same way the Unknown
867
1126
  // EFFECT does. ONE copy of that resolution, shared with the disclosure side — see resolveReasonClasses.
868
- const reasonAcc = resolveReasonClasses(functions, callgraph);
1127
+ const reasonAcc = resolveReasonClasses(functions, callgraph, units);
869
1128
  // ⟨0.24⟩ ONE definition of a function's Net destination classes for this run (netClassResolver above):
870
1129
  // the report's own field when it carries one, else the derivation from the surfaces the caller supplied.
871
1130
  // The gate's TEST and the class list it REPORTS both read it, so the two can't disagree about one function.
872
- const netClassOf = netClassResolver(incomplete, partners, netClasses);
1131
+ const netClassOf = netClassResolver(incomplete, partners, netClasses, units);
873
1132
  for (const f of functions) {
1133
+ // ⟨0.32⟩ the KEY identifies the unit; `f.fn` is the NAME, and the name is what a policy SCOPE matches
1134
+ // and what the verdict row prints (§3.3.1 byte-equality with `scan --policy` rests on it).
1135
+ const uk = unitKey(units, f);
1136
+ const inferredOf = effectiveInferred(f, reasonAcc, units);
874
1137
  for (const r of pol.deny) {
875
1138
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
876
1139
  // `pure` (empty forbidden set) forbids every EFFECT — not `Unknown`, which is the §4 trust
@@ -878,8 +1141,8 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
878
1141
  // The reference engine (candor-java) and the rust deep engine exclude it identically; candor-ts
879
1142
  // wrongly counted an Unknown-only fn as a `pure` violation until 2026-07-09.
880
1143
  const hits = r.effects.length === 0
881
- ? f.inferred.filter((e) => e !== "Unknown")
882
- : f.inferred.filter((e) => r.effects.includes(e));
1144
+ ? inferredOf.filter((e) => e !== "Unknown")
1145
+ : inferredOf.filter((e) => r.effects.includes(e));
883
1146
  // Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
884
1147
  // TRANSITIVE reason classes include one of those; Net destination-class: a `deny Net[dest…]` keeps its
885
1148
  // Net hit only for a fn reaching one of those destinations, else tolerates (only asserted-safe ones).
@@ -887,16 +1150,16 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
887
1150
  // travels the call graph via f.hosts + f.incomplete, propagated transitively before the gate (scan.mjs).
888
1151
  // ⟨0.24⟩ BOTH now live in `classFilterExcludes`, so `fix-gate` and `unverified` apply the same filter
889
1152
  // instead of computing from the effect set alone (see that function's measurement).
890
- let kept = hits.filter((e) => !classFilterExcludes(r, f, e, reasonAcc, netClassOf));
1153
+ let kept = hits.filter((e) => !classFilterExcludes(r, f, e, reasonAcc, netClassOf, units));
891
1154
  // ⟨0.24⟩ …and LAST, because it overrides the matchers rather than joining them: an effect whose match
892
1155
  // this report cannot evidence is neither a violation nor a pass. The caller lists it as `unevaluated`.
893
- if (withhold && kept.length) kept = kept.filter((e) => !withhold(r, f.fn, e));
1156
+ if (withhold && kept.length) kept = kept.filter((e) => !withhold(r, uk, e));
894
1157
  if (kept.length) {
895
1158
  // When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
896
- const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
1159
+ const rc = kept.includes("Unknown") ? [...(reasonAcc.get(uk) ?? [])].sort() : undefined;
897
1160
  // ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
898
1161
  const ncv = kept.includes("Net") ? netClassOf(f) : undefined;
899
- push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv);
1162
+ push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv, f.hash);
900
1163
  }
901
1164
  }
902
1165
  for (const r of pol.allow) {
@@ -912,10 +1175,10 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
912
1175
  const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect)
913
1176
  || (r.effect === "Llm" && incomplete.get(f.fn)?.has("Net"));
914
1177
  if (reached.length === 0 || surfaceIncomplete) {
915
- push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
1178
+ push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``, undefined, undefined, f.hash);
916
1179
  } else {
917
1180
  const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
918
- if (bad.length) push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``);
1181
+ if (bad.length) push("AS-EFF-008", f.fn, [r.effect], `\`${f.fn}\` reaches { ${bad.join(", ")} } outside the allowlist: \`${r.raw}\``, undefined, undefined, f.hash);
919
1182
  }
920
1183
  }
921
1184
  }
@@ -933,7 +1196,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
933
1196
  queue.push(c);
934
1197
  }
935
1198
  }
936
- if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
1199
+ if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``, undefined, undefined, hashOf.get(fn));
937
1200
  }
938
1201
  }
939
1202
  // ⟨0.29⟩ AS-EFF-011 — `only A -> B …`: a fn in A may reach A and the listed scopes, NOTHING else. The
@@ -963,7 +1226,7 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
963
1226
  // ⟨0.29⟩ ITS OWN CODE, not `forbid`'s — a rule code is what a CI suppression keys on, and these two
964
1227
  // are opposite constructs. Sharing 009 would make an existing `forbid` suppression silently mute
965
1228
  // `only` violations its author never accepted.
966
- if (hit) push("AS-EFF-011", fn, [], `\`${fn}\` reaches \`${hit}\`, which this permission rule does not permit: \`${r.raw}\``);
1229
+ if (hit) push("AS-EFF-011", fn, [], `\`${fn}\` reaches \`${hit}\`, which this permission rule does not permit: \`${r.raw}\``, undefined, undefined, hashOf.get(fn));
967
1230
  }
968
1231
  }
969
1232
  // ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN