candor-ts 0.32.1 → 0.33.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/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
21
21
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
22
22
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
23
23
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
24
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.32)."*
24
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.33)."*
25
25
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
26
26
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
27
27
  >
package/README.md CHANGED
@@ -198,7 +198,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
198
198
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
199
199
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
200
200
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
201
- | `{ candor: { version, toolchain, spec: "0.32" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
201
+ | `{ candor: { version, toolchain, spec: "0.33" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
202
202
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
203
203
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
204
204
 
@@ -216,7 +216,7 @@ read the Rust source".
216
216
 
217
217
  ## Status
218
218
 
219
- 0.30.0, speaking candor-spec 0.32: the analysis core, the gate (`--policy` / `--gate-json` /
219
+ 0.30.0, speaking candor-spec 0.33: the analysis core, the gate (`--policy` / `--gate-json` /
220
220
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
221
221
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
222
222
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/lsp.mjs CHANGED
@@ -402,11 +402,12 @@ const zeroRulePolicyWarn = (what) =>
402
402
  * same reason ("there is no line to pin it to"); the activity overlay's line-0 diagnostic is not a
403
403
  * counter-example, because its record NAMES the edited file and this one names no file in the workspace.
404
404
  */
405
- function discloseIncompleteness(unanalyzed, outOfScope, unread) {
405
+ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules) {
406
406
  const causes = [];
407
- // The CLI's order (`unanalyzed` → `outOfScope` → `unread`), so a report tripping two of them reads the
408
- // same way here as it does in CI. The repairs genuinely differ — a parse to fix, a selector that
409
- // REACHES the code, a scan that was ASKED the question — so each cause carries its own.
407
+ // The CLI's order (`unanalyzed` → `outOfScope` → `unread` → `unaskedRules`), so a report tripping two
408
+ // of them reads the same way here as it does in CI. The repairs genuinely differ — a parse to fix, a
409
+ // selector that REACHES the code, a scan that was ASKED the question, a scan asked the SAME question —
410
+ // so each cause carries its own.
410
411
  if (unanalyzed.length)
411
412
  causes.push(`the report DECLARES ${unanalyzed.length} unit(s) candor could not analyze, and a gate `
412
413
  + `cannot be green over unanalyzed code:\n`
@@ -421,6 +422,16 @@ function discloseIncompleteness(unanalyzed, outOfScope, unread) {
421
422
  + `report because nothing looked, not because there are none, so nothing in them can be squiggled `
422
423
  + `here — re-scan the sources WITH this policy (candor-ts <dir> --policy <file>); a scan that was `
423
424
  + `never asked cannot certify what it never opened.`);
425
+ // ⟨0.33⟩ SPEC §2 ⟨0.33⟩ — a class the peek DID read, but under a deny set that does not cover this
426
+ // policy's own. Distinct from `unread` above: that is "nothing looked", this is "something looked, for
427
+ // a narrower question than the one this editor is asking now" — the peek is bounded to the PRODUCER's
428
+ // denied effects (⟨0.29⟩), so an empty finding there answers nothing about a rule it was never put.
429
+ if (unaskedRules?.length)
430
+ causes.push(`this report's peek was bounded by the deny set its producing scan held, and that set `
431
+ + `does not cover ${unaskedRules.length} rule(s) of this policy: ${unaskedRules.join(", ")}. The `
432
+ + `excluded files it reports as read were searched for OTHER effects, so nothing in them can be `
433
+ + `squiggled here — re-run the producing scan under THE SAME policy this editor is applying `
434
+ + `(candor-ts <dir> --policy <file>), not merely under a policy.`);
424
435
  if (!causes.length) return;
425
436
  warnLoudOnce(
426
437
  `candor-lsp: this report cannot support a GREEN gate — \`gate --report\` over the same bytes exits 2 `
@@ -470,15 +481,17 @@ function diagnosticsFor(docPath) {
470
481
  warnOnce(`candor-lsp: ${dpol.ignored.length} line(s) of the configured policy were DROPPED by the `
471
482
  + `parse, so the gate you are seeing is SMALLER than the gate that was written (SPEC §6.2 ⟨0.28⟩):\n`
472
483
  + dpol.ignored.map((g) => ` line ${g.line}: ${g.text}`).join("\n"));
473
- // ⟨0.21⟩/⟨0.30⟩/⟨0.32⟩ …AND THE CAUSES THAT MAKE THE GATE ITSELF INCOMPLETE — see
484
+ // ⟨0.21⟩/⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ …AND THE CAUSES THAT MAKE THE GATE ITSELF INCOMPLETE — see
474
485
  // `discloseIncompleteness` for the mechanism and the channel argument. Read through the SAME
475
486
  // `reportCompleteness` the CLI gate, `fix-gate`, `unverified` and the MCP tools read, so the editor
476
- // cannot come to a different view of one report from the CI job that judges the same commit. The
477
- // `unread` condition is applied HERE, to the value, for the reason the CLI states at its own call
478
- // site: one list, one condition, so two consumers of it cannot disagree about a run.
479
- const dcomp = Q.reportCompleteness(reportPrefix);
487
+ // cannot come to a different view of one report from the CI job that judges the same commit. `dpol.deny`
488
+ // rides along so `reportUnaskedRules` can compare THIS policy's own rules against the report's
489
+ // `scannedUnder` — the identical value the CLI reads for the same bytes (SPEC §2 ⟨0.33⟩). The `unread`
490
+ // condition is applied HERE, to the value, for the reason the CLI states at its own call site: one
491
+ // list, one condition, so two consumers of it cannot disagree about a run.
492
+ const dcomp = Q.reportCompleteness(reportPrefix, dpol.deny);
480
493
  discloseIncompleteness(dcomp.unanalyzed ?? [], dcomp.outOfScope ?? [],
481
- dpol.deny.length ? (dcomp.unread ?? []) : []);
494
+ dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? []);
482
495
  // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
483
496
  // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
484
497
  // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
@@ -651,6 +664,18 @@ function runWhatif(a) {
651
664
  + `${zcallers.length} caller(s) would inherit ${a.effect})`);
652
665
  return { unevaluated: policyZeroRules(activePolicyPath ?? "(policy)").unevaluated };
653
666
  }
667
+ // PART 70 — this command returned the RAW `r` unconditionally, so the shipped editor experience
668
+ // (VS Code + JetBrains both bundle this server) certified `ok` over bytes `gate --report` refuses on:
669
+ // the ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ scope causes never reached this route at all. Read off the SAME
670
+ // `reportCompleteness` the CLI whatif and `candor_whatif`/`diagnosticsFor` use, `unread` gated on this
671
+ // call's OWN `deny`/`pure` rules exactly as those two gate it — so the three channels cannot disagree
672
+ // about one report. `discloseIncompleteness` is the SAME log+showMessage channel `diagnosticsFor`
673
+ // already uses for the standing gate squiggles (dedup on the message text), so a report already
674
+ // disclosed there says nothing new here, and one not yet seen surfaces on the FIRST channel that asks.
675
+ const wcomp = Q.reportCompleteness(reportPrefix, wpol?.deny ?? []);
676
+ const wUnread = wpol?.deny?.length ? (wcomp.unread ?? []) : [];
677
+ const wUnasked = wcomp.unaskedRules ?? [];
678
+ discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked);
654
679
  const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
655
680
  const rules = [...new Set(r.violations.map((v) => v.rule))];
656
681
  // ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
@@ -685,7 +710,13 @@ function runWhatif(a) {
685
710
  }]);
686
711
  publishDiagnostics(a.uri);
687
712
  }
688
- return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
713
+ // PART 70 — the executeCommand RESULT is the document a thick client renders, so it takes the SAME
714
+ // `ok`-withdrawal the CLI and `candor_whatif` apply: `incomplete: true` in its place, `affected`/
715
+ // `violations` (and the one-liner/diagnostic above, built off the raw `r`) unchanged. A no-op on a
716
+ // complete report — byte-identical to the pre-fix `return r` — so every fixture without a scope cause
717
+ // is untouched.
718
+ return Q.advisoryAnswer(r, wcomp.unanalyzed, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest,
719
+ wcomp.outOfScope ?? [], wUnread, wUnasked);
689
720
  }
690
721
 
691
722
  // The candor.fix command: the SAME query-core `fix` the CLI (`query.mjs fix`) and MCP (`candor_fix`) run —
package/mcp.mjs CHANGED
@@ -345,7 +345,7 @@ const TOOLS = {
345
345
  run: (_a, p) => nestWithCaveat(p, "modules", () => Q.map(loadReportLoud(p))),
346
346
  },
347
347
  candor_whatif: {
348
- description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
348
+ description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check. ALWAYS CHECK for the presence of `ok`, never just its value: over a report this route cannot fully evaluate, `ok` is ABSENT and `{incomplete:true, ...}` takes its place — `affected`/`violations` still ship (a partial answer beats a refusal; this tool is consulted BEFORE an edit), but neither `true` nor `false` is a claim the input licenses. The causes are the same ones `candor_gate`/`candor_unverified` disclose: `unanalyzed` (candor could not read a file of the target's own code), `outOfScope` (the peek found a denied effect outside the scan's reach), `unread` (a class the scan never opened — gated on this call's OWN `deny`/`pure` rules, since only those depend on code outside the scan's scope), and `unaskedRules` (a class something DID open, but under a narrower deny set than this policy's own).",
349
349
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
350
350
  run: (a, p) => {
351
351
  // A GIVEN policy path is always read (confined, fail-closed) — the old `existsSync` guard made a
@@ -359,7 +359,17 @@ const TOOLS = {
359
359
  // blast radius it qualifies are withheld for the caveat document (see `zeroRuleCaveat`). A policy
360
360
  // that is NOT configured stays untouched: that is the honest way to say "I am not gating".
361
361
  if (policyAskedNothing(pol)) return zeroRuleCaveat(a.policy, p);
362
- return r;
362
+ // PART 70 — this tool returned the RAW `r` unconditionally, so the agent-facing surface certified
363
+ // `ok` over bytes `candor_gate`/`gate --report` refuse on: the ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ scope causes
364
+ // never reached it at all (measured RED four ways: outOfScope, unread class, cross-policy, and the
365
+ // pre-existing `unanalyzed` path this tool DID carry via `r` alone — none of them withdrew `ok`).
366
+ // Same `Q.advisoryAnswer` the CLI and `candor_unverified` apply, off the SAME `reportCompleteness`
367
+ // reader, so this channel cannot drift from the other two.
368
+ const wcomp = Q.reportCompleteness(p, pol?.deny ?? []);
369
+ const wUnread = pol?.deny?.length ? (wcomp.unread ?? []) : [];
370
+ const wUnasked = wcomp.unaskedRules ?? [];
371
+ return Q.advisoryAnswer(r, wcomp.unanalyzed, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest,
372
+ wcomp.outOfScope ?? [], wUnread, wUnasked);
363
373
  },
364
374
  },
365
375
  candor_fix: {
@@ -391,7 +401,7 @@ const TOOLS = {
391
401
  },
392
402
  },
393
403
  candor_gate: {
394
- description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `incomplete:true` means the gate CANNOT be green, and the key beside it says which of the three causes fired: `unanalyzed` (the report declares code candor could not analyze), `outOfScope` (the producer's peek NAMED a function outside the scan's scope performing an effect this policy denies), or `unread` (a class the producing scan never OPENED — its effects are absent because nothing looked, not because there are none; re-scan those sources WITH this policy). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
404
+ description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `incomplete:true` means the gate CANNOT be green, and the key beside it says which of the four causes fired: `unanalyzed` (the report declares code candor could not analyze), `outOfScope` (the producer's peek NAMED a function outside the scan's scope performing an effect this policy denies), `unread` (a class the producing scan never OPENED — its effects are absent because nothing looked, not because there are none; re-scan those sources WITH this policy), or `unaskedRules` (a class the producing scan's peek DID read, but under a deny set that does not cover this one — re-scan under THE SAME policy this tool is applying, not merely under a policy). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
395
405
  schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
396
406
  run: (a, p) => {
397
407
  let text, polPath = a.policy ?? null;
@@ -428,7 +438,10 @@ const TOOLS = {
428
438
  // judged-nothing, and — newly — `unanalyzed`) could disagree with each other on a file another
429
439
  // process rewrites between them.
430
440
  const pol = policyOrThrow(text, polPath);
431
- const g = Q.loadGateReport(p);
441
+ // ⟨0.33⟩ this tool's OWN rules ride along so `loadGateReport` can compare them against the gated
442
+ // report's `scannedUnder` (SPEC §2 ⟨0.33⟩) — the identical computation `gate --report` makes, off
443
+ // the same reader, so this agent-facing route cannot certify what the CLI would refuse.
444
+ const g = Q.loadGateReport(p, pol.deny);
432
445
  if (g.hardFail)
433
446
  throw new Error((g.corrupt.length
434
447
  ? `the report at prefix \`${clip(p)}\` has ${g.corrupt.length} present-but-unparseable §2 key(s) — a key that is THERE but of the wrong shape is corrupt input, not an empty one (SPEC §2 ⟨0.24⟩); coercing it to its empty value would turn corruption into a purity claim: ${g.corrupt.join("; ")}. `
@@ -472,9 +485,15 @@ const TOOLS = {
472
485
  // effects are absent from `functions` because nothing looked. Decided by the policy applied NOW
473
486
  // (only a `deny`/`pure` rule's answer depends on code outside the scan's scope), which is the
474
487
  // same condition both CLI routes apply, from the same value, once.
488
+ // · `unaskedRules` (⟨0.33⟩) — a class the producer's peek DID read, but under a deny set that
489
+ // does not cover this policy's own. Read straight off `g` (computed by `loadGateReport` from
490
+ // the `pol.deny` handed to it above), never re-derived: the identical value the CLI's
491
+ // `gate --report` reads for the same bytes.
475
492
  const gscope = g.outOfScope ?? [];
476
493
  const gunread = pol.deny.length ? g.unread : [];
477
- const incomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0;
494
+ const gunasked = g.unaskedRules ?? [];
495
+ const incomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0
496
+ || gunasked.length > 0;
478
497
  // ⟨0.24⟩ …and a report that JUDGED NOTHING is not an all-clear (SPEC §2's three-row table, bound to
479
498
  // every report-reading route by §3.1: "the obligation is on the reading, not on the route by which
480
499
  // the report arrived"). This tool is exactly such a route — it gates whatever `report` points at,
@@ -531,7 +550,8 @@ const TOOLS = {
531
550
  ? { incomplete: true,
532
551
  ...(g.unanalyzed.length ? { unanalyzed: g.unanalyzed } : {}),
533
552
  ...(gscope.length ? { outOfScope: gscope } : {}),
534
- ...(gunread.length ? { unread: gunread } : {}) }
553
+ ...(gunread.length ? { unread: gunread } : {}),
554
+ ...(gunasked.length ? { unaskedRules: gunasked } : {}) }
535
555
  : {};
536
556
  // ⟨0.24⟩ PRECEDENCE (SPEC §3.1 `7271c69`/`4c79958`): violation (1) > refusal (2) > incomplete (2), and
537
557
  // the REFUSAL SHAPE is the one the CLI writes — `ok:false`, `refused:true`, and NO `violations` KEY AT
@@ -560,10 +580,12 @@ const TOOLS = {
560
580
  + "over a report declaring code candor could NOT analyze, `ok` IS ABSENT and `{incomplete:true, "
561
581
  + "unanalyzed}` takes its place — a function in an unanalyzed file is missing from the report "
562
582
  + "entirely, so it cannot be enumerated as an unverified pass, and an empty array there is not "
563
- + "an all-clear (spec §3.2). `incomplete:true` also rides the two SCOPE causes, on the same "
583
+ + "an all-clear (spec §3.2). `incomplete:true` also rides the three SCOPE causes, on the same "
564
584
  + "terms as candor_gate: `outOfScope` (the producer's peek named a function outside the scan's "
565
- + "scope performing a denied effect) and `unread` (a class the producing scan never OPENED — "
566
- + "re-scan those sources WITH this policy). `ok` is ABSENT for a further reason too, and the entries that come "
585
+ + "scope performing a denied effect), `unread` (a class the producing scan never OPENED — "
586
+ + "re-scan those sources WITH this policy), and `unaskedRules` (a class the producing scan's "
587
+ + "peek DID read, but under a deny set that does not cover this one — re-scan under THE SAME "
588
+ + "policy this tool is applying, not merely under a policy). `ok` is ABSENT for a further reason too, and the entries that come "
567
589
  + "with it are the sharpest ones: where the policy narrows on evidence this report does not carry, "
568
590
  + "candor_gate REFUSES — and this verb then NAMES each function the gate could not judge, as "
569
591
  + "`{fn, rule, why}` where `why` is THE MISSING EVIDENCE and never a derived class, plus the gate's "
@@ -588,8 +610,11 @@ const TOOLS = {
588
610
  // ⟨0.28⟩ …and the `analyzed.count: 0` cause on the same terms (SPEC §2), read through the SAME
589
611
  // `reportCompleteness` the CLI and the descriptive tools use — one reader, so the two channels
590
612
  // cannot disagree about which reports judged nothing.
591
- const ucomp = Q.reportCompleteness(p);
592
613
  const upol = policyOrThrow(text, polPath);
614
+ // ⟨0.33⟩ this tool's OWN deny/pure rules ride along, computed before this call so
615
+ // `reportCompleteness` can compare them against the report's `scannedUnder` — the same reader
616
+ // `unverified --strict` and `fix-gate --strict` use on the CLI.
617
+ const ucomp = Q.reportCompleteness(p, upol.deny);
593
618
  // ⟨0.28⟩ the sharpest of the three: the verb whose job is "your green gate is not provably green"
594
619
  // answered `{ok: true, unverified: []}` over a policy that asked nothing. The empty list is withheld
595
620
  // for ⟨0.27⟩'s reason — a document that made no evaluation must not carry the finding key.
@@ -606,9 +631,12 @@ const TOOLS = {
606
631
  // only a `deny`/`pure` rule's answer depends on code outside the scan's scope, and `pure` rides
607
632
  // the `deny` vector, so this is `deny.length` rather than a search for the token.
608
633
  const uUnread = upol.deny.length ? (ucomp.unread ?? []) : [];
634
+ // ⟨0.33⟩ …and the FOURTH cause — computed by `reportCompleteness` above (structurally `[]` when
635
+ // `upol.deny` is empty), never re-derived here.
636
+ const uUnasked = ucomp.unaskedRules ?? [];
609
637
  return Q.advisoryAnswer(Q.unverified(loadReportLoud(p), upol, scopeMatches),
610
638
  ucomp.unanalyzed, ucomp.judgedNothing, ucomp.unreadable, ucomp.noManifest,
611
- ucomp.outOfScope ?? [], uUnread);
639
+ ucomp.outOfScope ?? [], uUnread, uUnasked);
612
640
  },
613
641
  },
614
642
  candor_containment: {
@@ -629,8 +657,14 @@ const TOOLS = {
629
657
  // authoritative empty {changes:[]}; the CLI exits 2 on the same miss) — but NOT --root-confined:
630
658
  // see resolveBaseline for the out-of-tree-baseline trust argument.
631
659
  const b = resolveBaseline(a.baseline);
660
+ // ⟨0.33⟩ …and the ⟨0.28⟩ manifest on the same terms, BOTH SIDES separately — the SAME
661
+ // `Q.gainsCompleteness` the sibling `candor_gains` tool below spreads, because `diff` rests on the
662
+ // identical two-report shape and fails the identical two ways (a short CURRENT `changes`, a soft
663
+ // BASELINE floor). MEASURED: this tool answered a real 3-function gain over a CURRENT report naming
664
+ // an unread exclusion class with no caveat at all — the CLI carried the same gap (query.mjs `diff`
665
+ // case) and is fixed alongside this. No human sees this channel, so the JSON key is the whole fix.
632
666
  return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
633
- ...Q.diff(loadReportLoud(p), loadReportLoud(b)) };
667
+ ...Q.diff(loadReportLoud(p), loadReportLoud(b)), ...Q.gainsCompleteness(p, b) };
634
668
  },
635
669
  },
636
670
  candor_gains: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.32.1",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.32)",
3
+ "version": "0.33.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.33)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -553,7 +553,7 @@ export function reportUnanalyzed(prefix) {
553
553
  * every run trains the reader to ignore it, the same reason ⟨0.15⟩ omits `coverage` when nothing is
554
554
  * uncovered.
555
555
  */
556
- export function reportCompleteness(prefix) {
556
+ export function reportCompleteness(prefix, denyRules = []) {
557
557
  // `judgedNothing` is the PER-FILE list, not the ANDed boolean the gate asks: the disclosure names
558
558
  // WHICH report judged nothing, so a locator with one silent member among several still hedges — the
559
559
  // semantics rust and java pin, and a repair the reader can aim (that file, not "somewhere here").
@@ -574,6 +574,13 @@ export function reportCompleteness(prefix) {
574
574
  // this field directly for their EXIT code and apply the gate's own condition to it: only a
575
575
  // `deny`/`pure` rule's answer depends on code outside the scan's scope.
576
576
  unread: reportUnread(prefix),
577
+ // ⟨0.33⟩ …and the rules THIS CALLER'S policy holds that a peeked class's producer was never
578
+ // asked about (`reportUnaskedRules`) — the SAME arm shape as `unread` one line up, and bound
579
+ // by the identical ⟨0.24⟩ pessimism MUST. `denyRules` defaults to `[]`, which is the
580
+ // STRUCTURAL carve-out for every caller of this function that carries no policy at all (the
581
+ // majority — `show`/`where`/`callers`/`map`/`tour`/… never pass one), so this key is a silent
582
+ // no-op for them rather than a sixth call site to remember the condition at.
583
+ unaskedRules: reportUnaskedRules(prefix, denyRules),
577
584
  ...(() => {
578
585
  const o = reportOutOfScope(prefix);
579
586
  // A corrupt key rides `unreadable`, which is ALREADY an arm of the strict exit — so the
@@ -634,7 +641,12 @@ export function reportCompleteness(prefix) {
634
641
  */
635
642
  export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
636
643
  || c.noManifest?.length || c.unreadable?.length
637
- || c.outOfScope?.length || c.unread?.length));
644
+ || c.outOfScope?.length || c.unread?.length
645
+ // ⟨0.33⟩ a peek bounded by a deny set narrower than this
646
+ // caller's own — see `reportUnaskedRules`. Structurally `[]`
647
+ // for every caller (the majority) that never passes `denyRules`
648
+ // to `reportCompleteness`, so this arm is a no-op for them.
649
+ || c.unaskedRules?.length));
638
650
 
639
651
  /** ⟨0.30⟩ The peek's findings across the reports under a locator. Read leniently HERE (a malformed key is
640
652
  * the gate's refusal to make, and this feeds a disclosure) but non-emptiness raises the same hedge the
@@ -689,6 +701,57 @@ export function reportUnread(prefix) {
689
701
  return [...out];
690
702
  }
691
703
 
704
+ /**
705
+ * ⟨0.33⟩ THE ADVISORY TWIN of `loadGateReport`'s `scannedUnder` subset test — SPEC §2 ⟨0.33⟩'s "AND THE
706
+ * ADVISORY VERBS FOLLOW IT": the rules THIS caller's policy holds that a report's producer, under the
707
+ * SAME peek-was-bounded reasoning, was never asked about. One helper for the gate route and this one,
708
+ * deliberately, so the ⟨0.24⟩ pessimism relation (an advisory verb must never be LESS sensitive to
709
+ * incompleteness than the gate over the same bytes) cannot drift the way it already has three times in
710
+ * this family — `unanalyzed`, `outOfScope`, `excluded[].peeked` each reached `gate --report` before they
711
+ * reached `unverified`/`fix-gate`, and each time it was a NEW cause landing at one site and not its
712
+ * siblings.
713
+ *
714
+ * STRUCTURAL CARVE-OUT FIRST: `denyRules` empty means this caller's policy holds no `deny`/`pure` rule at
715
+ * all (an `allow`/`forbid`/`only`-only policy, or none) — an EMPTY canonical set, which is a subset of
716
+ * everything — so the per-report loop below is skipped rather than run to compute nothing. This is the
717
+ * same carve-out `loadGateReport` takes, and it is what keeps a policy-less verb like `tour` inert here:
718
+ * `reportCompleteness`'s default `denyRules = []` makes this function a no-op for every caller that never
719
+ * passes one.
720
+ *
721
+ * LENIENT, like `reportOutOfScope`/`reportUnread` beside it and for the same reason: a malformed
722
+ * `scannedUnder` is the GATE's refusal to make (`loadGateReport` reads the same bytes STRICTLY and NAMES
723
+ * the key in a refusal), and this feeds a disclosure rather than an exit code. A garbled or absent
724
+ * `scannedUnder` reads here as the EMPTY SET — the same fail-closed reading `loadGateReport` takes, just
725
+ * reached without a refusal document to carry it: this function may only ever be MORE cautious than
726
+ * silence, never less.
727
+ *
728
+ * PER REPORT, never over the union of a report set: `scannedUnder` and `peeked` are facts about ONE
729
+ * producing scan, so a locator naming a policy-scanned report beside a no-policy sibling must not let the
730
+ * first one's deny set answer for the second one's peeked classes. The per-file `theirs` set is built
731
+ * fresh for every file and never merged across them; only the resulting MISSING RULE STRINGS are unioned
732
+ * (deduplicated, code-point sorted) across a multi-report prefix, exactly as `loadGateReport` does.
733
+ */
734
+ export function reportUnaskedRules(prefix, denyRules) {
735
+ const mine = denyRules?.length ? canonicalDenySet(denyRules) : [];
736
+ if (!mine.length) return [];
737
+ const files = (prefix.endsWith(".json") && fs.existsSync(prefix)) ? [prefix] : reportFilesAt(prefix);
738
+ const out = new Set();
739
+ for (const f of files) {
740
+ try {
741
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
742
+ const exc = Array.isArray(d?.excluded) ? d.excluded : [];
743
+ const peeked = exc.some((e) => e && typeof e === "object" && e.peeked === true
744
+ && e.judgedElsewhere !== true);
745
+ if (!peeked) continue;
746
+ const su = d?.scannedUnder;
747
+ const theirs = (su && typeof su === "object" && !Array.isArray(su) && Array.isArray(su.deny))
748
+ ? new Set(su.deny.filter((x) => typeof x === "string")) : new Set();
749
+ for (const r of mine) if (!theirs.has(r)) out.add(r);
750
+ } catch { /* unparseable TEXT is `unreadable`'s business, not this key's */ }
751
+ }
752
+ return [...out].sort(byCodePoint);
753
+ }
754
+
692
755
  /**
693
756
  * ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
694
757
  * is nothing to disclose, so `{ ...data, ...completenessFields(c) }` is byte-identical to `data` on a
@@ -795,10 +858,10 @@ export const absorbCompleteness = (a, b) => ({
795
858
  * that has no exit code for it to matter to.)
796
859
  */
797
860
  export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = [],
798
- outOfScope = [], unread = []) {
861
+ outOfScope = [], unread = [], unaskedRules = []) {
799
862
  const unevaluated = body?.unevaluated;
800
863
  if (!unanalyzed?.length && !unevaluated?.length && !judgedNothing?.length && !unreadable?.length
801
- && !noManifest?.length && !outOfScope?.length && !unread?.length)
864
+ && !noManifest?.length && !outOfScope?.length && !unread?.length && !unaskedRules?.length)
802
865
  return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
803
866
  const { ok, ...rest } = body; // eslint-disable-line no-unused-vars -- omitted BY DESIGN
804
867
  // The array of report paths, same key and same shape as `completenessFields` — ONE wire spelling for
@@ -809,18 +872,21 @@ export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable
809
872
  // never emitted a manifest cannot support it (row 3: *no manifest, no claim*).
810
873
  const judged = { ...(judgedNothing?.length ? { judgedNothing } : {}),
811
874
  ...(noManifest?.length ? { noManifest } : {}) };
812
- // ⟨0.30⟩/⟨0.32⟩ THE TWO SCOPE CAUSES — the ones the `--strict` exit has consulted since ⟨0.30⟩ while this
813
- // document did not, which is the document/exit split in its worse direction. MEASURED 2026-08-24 on
814
- // `fix-gate --strict` and `unverified --strict` over a report carrying `outOfScope`: BOTH printed
815
- // `{"ok": true, …: []}` **at exit 2** — the exit was right and the document certified. A CI wrapper reads
816
- // the exit; an agent reads the document, and the agent channel is the one that cannot ask a follow-up
817
- // question. Same keys and same order as the gate's verdict document, so one consumer parses both.
875
+ // ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ THE THREE SCOPE CAUSES — the ones the `--strict` exit has consulted since ⟨0.30⟩
876
+ // while this document did not, which is the document/exit split in its worse direction. MEASURED
877
+ // 2026-08-24 on `fix-gate --strict` and `unverified --strict` over a report carrying `outOfScope`: BOTH
878
+ // printed `{"ok": true, …: []}` **at exit 2** — the exit was right and the document certified. A CI
879
+ // wrapper reads the exit; an agent reads the document, and the agent channel is the one that cannot ask
880
+ // a follow-up question. Same keys and same order as the gate's verdict document, so one consumer parses
881
+ // both. `unaskedRules` is the ⟨0.33⟩ fourth cause — a peeked class read under a deny set narrower than
882
+ // this caller's own — riding beside its two siblings for the identical reason.
818
883
  const scope = { ...(outOfScope?.length ? { outOfScope } : {}),
819
- ...(unread?.length ? { unread } : {}) };
884
+ ...(unread?.length ? { unread } : {}),
885
+ ...(unaskedRules?.length ? { unaskedRules } : {}) };
820
886
  // Key order matches the gate's verdict document: the finding, then `unevaluated`, then the manifest.
821
887
  if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...scope, ...judged };
822
888
  return (judgedNothing?.length || noManifest?.length || unreadable?.length
823
- || outOfScope?.length || unread?.length)
889
+ || outOfScope?.length || unread?.length || unaskedRules?.length)
824
890
  ? { ...rest, incomplete: true, ...scope, ...judged } : rest;
825
891
  }
826
892
 
@@ -868,10 +934,20 @@ export function loadReport(prefix) {
868
934
  * refusal to name the key), entry-level and envelope-level; non-empty implies `hardFail`.
869
935
  * `judgedNothing` is the ⟨0.24⟩ reading of `analyzed.count` (see `claimsToHaveJudgedNothing`).
870
936
  */
871
- export function loadGateReport(prefix) {
937
+ export function loadGateReport(prefix, denyRules = []) {
872
938
  const files = reportFilesAt(prefix);
873
939
  const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [], netPartners = [],
874
940
  unread = [];
941
+ // ⟨0.33⟩ THIS GATE'S OWN canonical deny/pure rules, computed ONCE rather than per report — a subset
942
+ // test against ten sibling reports must ask the identical question ten times, not re-derive it. Empty
943
+ // when this route carries no `deny`/`pure` rule at all (an `allow`/`forbid`/`only`-only policy, or no
944
+ // policy), which is the STRUCTURAL carve-out SPEC §2 ⟨0.33⟩ requires: an empty rule set is a subset of
945
+ // everything, so the per-file loop below contributes nothing to `unasked` however the report reads.
946
+ const mine = denyRules?.length ? canonicalDenySet(denyRules) : [];
947
+ // ⟨0.33⟩ the rules NO gated report's producer was asked about — a SET, because a multi-report prefix
948
+ // otherwise repeats one missing rule once per sibling. Deduplicated and code-point sorted at the return
949
+ // below, the same collation `unread`'s dedup and the verdict's `zeroMatch` use.
950
+ const unasked = new Set();
875
951
  let hardFail = false, analyzed = 0;
876
952
  // ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
877
953
  // siblings, because the union of several reports has judged something as soon as ONE of them has — the
@@ -882,6 +958,12 @@ export function loadGateReport(prefix) {
882
958
  let judgedNothing = true;
883
959
  for (const f of files) {
884
960
  let parsed;
961
+ // ⟨0.33⟩ did THIS FILE's peek read a class it does not carry `judgedElsewhere: true` for? The
962
+ // precondition of the cross-policy refusal, reset per file because `scannedUnder` and `peeked` are
963
+ // facts about ONE producing scan (SPEC §2 ⟨0.33⟩: "the condition is PER REPORT, never over the union
964
+ // of a report set") — a policy-scanned sibling's deny set must never answer for a no-policy sibling's
965
+ // peeked classes.
966
+ let filePeeked = false;
885
967
  try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); }
886
968
  catch { console.error(`candor-ts: report ${f} failed to parse — its functions are OMITTED from this gate (corrupt or mid-write); re-run the scan`); hardFail = true; continue; }
887
969
  const { entries, corrupt: entryCorrupt } = normFns(parsed, f);
@@ -990,7 +1072,31 @@ export function loadGateReport(prefix) {
990
1072
  // `#[serde(default)]` does on the same field: a producer that does not say it read the class has
991
1073
  // not said it read the class.
992
1074
  else if (e.peeked !== true && e.judgedElsewhere !== true) unread.push(e.class);
1075
+ // ⟨0.33⟩ …and the mirror case: a class the peek DID read (and that is not a `judgedElsewhere`
1076
+ // copy of already-judged code) is exactly the class whose clean answer was bounded by THIS
1077
+ // report's `scannedUnder` — the precondition below.
1078
+ else if (e.peeked === true && e.judgedElsewhere !== true) filePeeked = true;
993
1079
  }
1080
+ // ⟨0.33⟩ THE QUESTION THIS REPORT'S PEEK WAS PUT — read STRICTLY, like every other verdict-bearing §2
1081
+ // key on this route (`excluded`/`outOfScope` immediately above). A non-object, or a `deny` that is
1082
+ // not an array of strings, IMPEACHES THE DOCUMENT: the safe-LOOKING coercion here is the FAIL-OPEN
1083
+ // direction — "the producer held these rules" — the mirror of `peeked`'s own fail-open, where the
1084
+ // safe-looking coercion was "no exclusions". ABSENT is untouched: a pre-⟨0.33⟩ producer never carried
1085
+ // this key, and the subset test below reads that as the EMPTY SET (refuses), never as a licence.
1086
+ let fileScannedUnder = null;
1087
+ if ("scannedUnder" in parsed) {
1088
+ const su = parsed.scannedUnder;
1089
+ if (!su || typeof su !== "object" || Array.isArray(su) || !Array.isArray(su.deny)
1090
+ || su.deny.some((x) => typeof x !== "string"))
1091
+ corrupt.push(`${f}: \`scannedUnder\` (expected an object \`{deny: [string, …]}\`)`);
1092
+ else fileScannedUnder = new Set(su.deny);
1093
+ }
1094
+ // ⟨0.33⟩ …and, when this file's peek read something, which of THIS gate's own rules that report's
1095
+ // producer was never asked about. An absent `fileScannedUnder` reads as the empty set — never a
1096
+ // licence — so a pre-⟨0.33⟩ producer's `peeked: true` fails closed here exactly as SPEC §2 ⟨0.33⟩
1097
+ // requires. A class the peek never opened at all is `unread`'s gap, not this one's.
1098
+ if (mine.length && filePeeked)
1099
+ for (const r of mine) if (!(fileScannedUnder ?? new Set()).has(r)) unasked.add(r);
994
1100
  // ⟨0.31⟩ the producer's PARTNER PROVENANCE, carried through verbatim and never recomputed — this
995
1101
  // route has no target to anchor `net-partner` at, and re-classifying through the consumer's own
996
1102
  // config is the re-derivation §3.1 forbids. A prefix can match several reports (a workspace writes
@@ -1021,7 +1127,12 @@ export function loadGateReport(prefix) {
1021
1127
  // same class once per member in a message whose job is to name what to re-scan. The verdict is unmoved
1022
1128
  // either way; only the sentence is.
1023
1129
  return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, netPartners,
1024
- unread: [...new Set(unread)], hardFail: hardFail || corrupt.length > 0, corrupt };
1130
+ unread: [...new Set(unread)],
1131
+ // ⟨0.33⟩ SPEC §2 — the gate's own rules that some gated report's producer was never asked
1132
+ // about; `[]` when `mine` is empty (no deny/pure rule at all) or every peeked class's producer
1133
+ // covered them.
1134
+ unaskedRules: [...unasked].sort(byCodePoint),
1135
+ hardFail: hardFail || corrupt.length > 0, corrupt };
1025
1136
  }
1026
1137
  // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
1027
1138
  // true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
@@ -2062,6 +2173,33 @@ export function ruleUpgrade(r) {
2062
2173
  return [`deny ${effs}${suffix}`, `deny ${effs} Unknown${suffix}`];
2063
2174
  }
2064
2175
 
2176
+ /**
2177
+ * ⟨0.33⟩ A deny/pure rule LIST as a canonical SET — the §6.2 spelling of every rule THE MATCHER USED,
2178
+ * deduplicated and CODE-POINT sorted (`byCodePoint`, the same collation the `zeroMatch` verdict list
2179
+ * already uses), so one policy produces one document however its lines were ordered, and a consumer's
2180
+ * subset test is a plain membership test rather than an order-sensitive comparison.
2181
+ *
2182
+ * RENDERED THROUGH `ruleUpgrade`'s own SOURCE spelling — the first element of the pair it already quotes
2183
+ * back to an operator for the provable-purity upgrade — rather than a second renderer. That is the same
2184
+ * move candor-java made (`Policy.canonicalDenyRule`, shared with `ruleUpgrade`): the string an operator is
2185
+ * quoted and the string a gate compares cannot become two spellings of one rule.
2186
+ *
2187
+ * NOT effect NAMES: `pure` is a rule with an EMPTY effect list meaning "every effect except Unknown", so
2188
+ * flattening to names loses it entirely and the STRICTEST policy would compare equal to an empty one — the
2189
+ * four-way false all-clear ⟨0.30⟩ closed on the peek itself, arriving one layer out. NOT the raw policy
2190
+ * line either: §3.1 already notes alias expansion breaks byte-equality, so two configs defining
2191
+ * `unknown-alias corp` differently would give the identical raw line `deny Unknown[corp]` two meanings.
2192
+ * `ruleUpgrade`'s rendering is the EXPANDED form — post-alias, post-`.candor/config` — because that is
2193
+ * what the peek actually asked (SPEC §2 ⟨0.33⟩, `scannedUnder.deny`).
2194
+ *
2195
+ * One element per RULE — a rule denying several effects is ONE element, not one per effect.
2196
+ */
2197
+ export function canonicalDenySet(rules) {
2198
+ const out = new Set();
2199
+ for (const r of rules ?? []) out.add(ruleUpgrade(r)[0]);
2200
+ return [...out].sort(byCodePoint);
2201
+ }
2202
+
2065
2203
  /** The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
2066
2204
  * Unknown, sits in a pure/deny scope, and PASSES that rule (carries none of its forbidden real effects) —
2067
2205
  * so its compliance is asserted but not verified (the Unknown could hide the very effect the rule forbids;
package/query.mjs CHANGED
@@ -526,8 +526,16 @@ const P = {
526
526
  console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
527
527
  for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
528
528
  },
529
- diff: (d) => {
530
- if (!d.changes.length) { console.log("candor diff — no effect changes vs the baseline. ✓"); return; }
529
+ // ⟨0.33⟩ `hedge` mirrors `gains`' one line up: EITHER side's report incomplete withdraws the ✓, because
530
+ // "no effect changes ✓" IS the prose spelling of `changes: []` and this alarm-adjacent verb licenses
531
+ // that reassurance only over bytes that could actually see a change either way.
532
+ diff: (d, hedge) => {
533
+ if (!d.changes.length) {
534
+ console.log(hedge
535
+ ? `candor diff — no effect changes candor COULD SEE vs the baseline ${NOT_A("nothing changed")}.`
536
+ : "candor diff — no effect changes vs the baseline. ✓");
537
+ return;
538
+ }
531
539
  console.log(`candor diff — ${d.changes.length} function(s) changed vs the baseline:`);
532
540
  for (const c of d.changes) console.log(` \`${c.fn}\`${c.gained.length ? ` +${c.gained.join(",")}` : ""}${c.lost.length ? ` -${c.lost.join(",")}` : ""}`);
533
541
  },
@@ -602,7 +610,7 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
602
610
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
603
611
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
604
612
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
605
- const SPEC_VERSION = "0.32";
613
+ const SPEC_VERSION = "0.33";
606
614
 
607
615
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
608
616
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -1518,11 +1526,37 @@ switch (cmd) {
1518
1526
  const versionMismatch = engineV && baseV && engineV !== baseV;
1519
1527
  if (versionMismatch)
1520
1528
  console.error(`candor-ts: ⚠ baseline @${baseV} ≠ engine @${engineV} — some changes may be the engine reclassifying, not your code. Treat an engine swap as baseline-invalidating: review, then regenerate the baseline.`);
1521
- put(args, { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes }, P.diff);
1529
+ // ⟨0.33⟩ SPEC §2 — THE SAME MUST `gains` (one case up) ALREADY CARRIES, ON A VERB THAT RESTS ON THE
1530
+ // IDENTICAL TWO REPORTS AND HAD NO COMPLETENESS READER AT ALL. `diff` sat one case above
1531
+ // `containment`'s `putAnswer(...)`, untouched since ⟨0.28⟩ named `where`/`callers`/`show`/`map`/
1532
+ // `reachable`/`impact`/`containment`/`gains`/`blindspots` — measured here 2026-08-26 over a CURRENT
1533
+ // report naming one `excluded[].peeked: false` class: `{"changes":[…3 real gains…]}` at exit 1, no
1534
+ // caveat on EITHER channel, from the CLI *and* the MCP `candor_diff` tool (mcp.mjs carried the same
1535
+ // gap — see the fix there).
1536
+ //
1537
+ // `diff` fails in the SAME two directions `gains` does, because it rests on the same pair: an
1538
+ // incomplete CURRENT means a real gained/lost effect is MISSING from `changes` (the false all-clear
1539
+ // this verb's own `changes.some((c) => c.gained.length)` exit exists to catch), while an incomplete
1540
+ // BASELINE means a change that was ALWAYS there can read as newly appeared. `gains` resolved this
1541
+ // exact shape with a PREFIXED spread (`gainsCompletenessFields`), not `absorbCompleteness` (which
1542
+ // `containment <baseline>` uses to fold two manifests into ONE flag for a SINGLE leak set) — reused
1543
+ // verbatim here rather than minting a fourth spelling for what is structurally `gains`' shape again.
1544
+ const dCur = reportCompleteness(curPrefix), dBase = reportCompleteness(basePrefix);
1545
+ const diffDoc = { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes,
1546
+ ...gainsCompletenessFields(dCur, dBase) };
1547
+ if (wantJsonOut(args)) emit(diffDoc);
1548
+ else {
1549
+ incompleteAnswerNote(dCur, "the changes below are computed only over effects candor read in the CURRENT tree and may be SHORT",
1550
+ "An effect gained or lost in an unread unit of the current tree is not in the list below.");
1551
+ incompleteAnswerNote(dBase, "the BASELINE half of this comparison is itself partial and the floor it sets is soft",
1552
+ "An effect living in an unread unit of the baseline reads as a newly gained or lost change here.");
1553
+ P.diff(diffDoc, mustHedge(dCur) || mustHedge(dBase));
1554
+ }
1522
1555
  // diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
1523
1556
  // same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
1524
1557
  // so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
1525
- // closed, queries disclose).
1558
+ // closed, queries disclose). Verdict-preserving here too: the exit reads the raw `changes`, untouched
1559
+ // by the caveat, exactly as ⟨0.28⟩ ruled for `gains`.
1526
1560
  process.exit(!versionMismatch && changes.some((c) => c.gained.length) ? 1 : 0);
1527
1561
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
1528
1562
  }
@@ -1922,7 +1956,10 @@ switch (cmd) {
1922
1956
  // consulted BEFORE an edit, where the alternative is the operator guessing. The exit is UNCHANGED:
1923
1957
  // this verb has no `--strict`, §3.2 rules no exit for it, and inventing one is the failure mode the
1924
1958
  // clause it lives beside exists to prevent.
1925
- const wcomp = reportCompleteness(prefix);
1959
+ // ⟨0.33⟩ this verb's OWN deny/pure rules ride along — see the `fix-gate`/`unverified` sites for the
1960
+ // rule. `pol` is OPTIONAL here (unlike those two), so a bare `whatif` with no `--policy` passes `[]`,
1961
+ // the structural carve-out `reportUnaskedRules` already takes for every no-policy caller.
1962
+ const wcomp = reportCompleteness(prefix, pol?.deny ?? []);
1926
1963
  const wunan = wcomp.unanalyzed;
1927
1964
  if (wunan.length)
1928
1965
  console.error(`candor-ts: whatif is NOT a complete answer — the report declares ${wunan.length} unit(s) candor could not analyze (disclosed under \`unanalyzed\`); \`ok\` is omitted because neither value is a statement the input licenses`);
@@ -1936,7 +1973,19 @@ switch (cmd) {
1936
1973
  // verdict AND the blast radius it qualifies are withheld in favour of the caveat document. The exit
1937
1974
  // is UNCHANGED (0: with no rules, `violations` was empty by construction on this path anyway).
1938
1975
  if (policyFile && policyAskedNothing(pol)) { emitZeroRuleCaveat("whatif", policyFile, wcomp); process.exit(0); }
1939
- emit(advisoryAnswer(r, wunan, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest));
1976
+ // ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ PART 70 — THE THREE SCOPE CAUSES `whatif` NEVER RECEIVED. `advisoryAnswer` has
1977
+ // carried these three parameters since the `fix-gate`/`unverified` rungs; this call site simply never
1978
+ // passed them, so `whatif` answered `ok` where the gate refuses over the identical bytes — MEASURED,
1979
+ // all three cells RED. `unread` is gated on `pol.deny.length` exactly as the fix-gate/unverified sites
1980
+ // gate it (only a `deny`/`pure` rule's answer depends on code outside the scan's scope; a bare
1981
+ // `whatif` with no policy has no such rule, so `=== true` here rather than a truthy check — a length
1982
+ // that happens to be 0 must NOT read as "gated open").
1983
+ const wUnread = pol?.deny?.length ? (wcomp.unread ?? []) : [];
1984
+ // ⟨0.33⟩ the fourth cause — computed by `reportCompleteness` above (structurally `[]` when `pol` is
1985
+ // absent or `pol.deny` is empty), never re-derived here.
1986
+ const wUnasked = wcomp.unaskedRules ?? [];
1987
+ emit(advisoryAnswer(r, wunan, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest,
1988
+ wcomp.outOfScope ?? [], wUnread, wUnasked));
1940
1989
  process.exit(r.violations.length ? 1 : 0);
1941
1990
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
1942
1991
  }
@@ -2001,7 +2050,10 @@ switch (cmd) {
2001
2050
  // engine was the only one right, which is the signal to fix the other three rather than argue.
2002
2051
  { const wp = wholePolicyUnanswerable(fgpol, "a report route");
2003
2052
  if (wp.unevaluated.length) fgr.unevaluated = [...(fgr.unevaluated ?? []), ...wp.unevaluated]; }
2004
- const fgComp = reportCompleteness(prefix);
2053
+ // ⟨0.33⟩ this verb's OWN deny/pure rules ride along so `reportCompleteness` can compare them against
2054
+ // every gated report's `scannedUnder` — the ⟨0.24⟩ pessimism MUST, off the SAME reader `gate --report`
2055
+ // uses (`reportUnaskedRules`).
2056
+ const fgComp = reportCompleteness(prefix, fgpol.deny);
2005
2057
  const fgUnan = fgComp.unanalyzed;
2006
2058
  if (fgUnan.length) advisoryIncompleteNote("fix-gate", fgUnan);
2007
2059
  // ⟨0.28⟩ …and the count-0 cause, which reaches the DOCUMENT and the PROSE but deliberately NOT the exit
@@ -2027,12 +2079,15 @@ switch (cmd) {
2027
2079
  // `deny`/`pure` rule's answer depends on code outside the scan's scope), computed ONCE and handed to
2028
2080
  // both the document and the exit below so the two cannot disagree about a run.
2029
2081
  const fgUnread = fgpol.deny.length ? (fgComp.unread ?? []) : [];
2030
- // ⟨0.30⟩/⟨0.32⟩ THE TWO SCOPE CAUSES NOW REACH THE DOCUMENT, not just the exit. MEASURED before this
2031
- // argument existed: over a report carrying `outOfScope`, this verb printed `{"ok": true, "remedies":
2082
+ // ⟨0.33⟩ the FOURTH cause — computed by `reportCompleteness` (structurally `[]` when `fgpol.deny` is
2083
+ // empty), never re-derived here, for the identical reason `fgUnread` above rides one value.
2084
+ const fgUnasked = fgComp.unaskedRules ?? [];
2085
+ // ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ THE THREE SCOPE CAUSES NOW REACH THE DOCUMENT, not just the exit. MEASURED before
2086
+ // this argument existed: over a report carrying `outOfScope`, this verb printed `{"ok": true, "remedies":
2032
2087
  // []}` AT EXIT 2 — the exit obeyed §3.2's "never MORE certain than the gate" while the document it
2033
- // printed certified. The exit expression below is unchanged for `outOfScope` and gains the ⟨0.32⟩ arm.
2088
+ // printed certified. The exit expression below is unchanged for `outOfScope` and gains the ⟨0.32⟩/⟨0.33⟩ arms.
2034
2089
  emit(advisoryAnswer(fgr, fgUnan, fgComp.judgedNothing, fgComp.unreadable, fgComp.noManifest,
2035
- fgComp.outOfScope ?? [], fgUnread));
2090
+ fgComp.outOfScope ?? [], fgUnread, fgUnasked));
2036
2091
  // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger (SPEC §3.2's pessimism relation): `gate
2037
2092
  // --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
2038
2093
  // got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
@@ -2044,7 +2099,10 @@ switch (cmd) {
2044
2099
  // MEASURED, `gate --report` exited 2 while this printed a clean answer at exit 0 over the same bytes.
2045
2100
  // ⟨0.32⟩ …and the class nothing OPENED, for the same MUST: `gate --report` exits 2 over it, so a verb
2046
2101
  // that answered 0 on the same bytes would have got FURTHER than the gate.
2047
- process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgUnread.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
2102
+ // ⟨0.33⟩ …and the class something DID open, under a deny set narrower than this verb's own, for the
2103
+ // identical MUST — "`--strict` answers 2 wherever `gate --report` would".
2104
+ process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgUnread.length
2105
+ || fgUnasked.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
2048
2106
  break; // unreachable
2049
2107
  }
2050
2108
  case "unverified": {
@@ -2086,7 +2144,8 @@ switch (cmd) {
2086
2144
  // engine was the only one right, which is the signal to fix the other three rather than argue.
2087
2145
  { const wp = wholePolicyUnanswerable(upol, "a report route");
2088
2146
  if (wp.unevaluated.length) r.unevaluated = [...(r.unevaluated ?? []), ...wp.unevaluated]; }
2089
- const uComp = reportCompleteness(prefix);
2147
+ // ⟨0.33⟩ this verb's OWN deny/pure rules ride along — see the `fix-gate` site above for the rule.
2148
+ const uComp = reportCompleteness(prefix, upol.deny);
2090
2149
  const uUnan = uComp.unanalyzed;
2091
2150
  if (uUnan.length) advisoryIncompleteNote("unverified", uUnan);
2092
2151
  // ⟨0.28⟩ …and the count-0 cause. MEASURED before this line: `{ok: true, unverified: []}` over a report
@@ -2105,17 +2164,21 @@ switch (cmd) {
2105
2164
  emitZeroRuleCaveat("unverified", policyFile, uComp);
2106
2165
  process.exit(uUnan.length || uComp.unreadable.length ? (strict ? 2 : 0) : 0);
2107
2166
  }
2108
- // ⟨0.30⟩/⟨0.32⟩ the two scope causes, in the document as well as the exit — see the fix-gate site for
2109
- // the measurement (`{"ok": true, "unverified": []}` at exit 2 over a report carrying `outOfScope`).
2167
+ // ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ the three scope causes, in the document as well as the exit — see the fix-gate
2168
+ // site for the measurement (`{"ok": true, "unverified": []}` at exit 2 over a report carrying
2169
+ // `outOfScope`).
2110
2170
  const uUnread = upol.deny.length ? (uComp.unread ?? []) : [];
2171
+ const uUnasked = uComp.unaskedRules ?? [];
2111
2172
  emit(advisoryAnswer(r, uUnan, uComp.judgedNothing, uComp.unreadable, uComp.noManifest,
2112
- uComp.outOfScope ?? [], uUnread));
2173
+ uComp.outOfScope ?? [], uUnread, uUnasked));
2113
2174
  // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
2114
2175
  // before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
2115
2176
  // `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
2116
2177
  // ⟨0.30⟩ the peek's findings too — see the fix-gate exit above for the ⟨0.24⟩ MUST this satisfies.
2117
2178
  // ⟨0.32⟩ …and the class nothing OPENED — see the fix-gate exit above.
2118
- process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || uUnread.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
2179
+ // ⟨0.33⟩ …and the class something DID open, under a narrower deny set — see the fix-gate exit above.
2180
+ process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || uUnread.length
2181
+ || uUnasked.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
2119
2182
  break; // unreachable
2120
2183
  }
2121
2184
  case "gate": {
@@ -2235,7 +2298,10 @@ switch (cmd) {
2235
2298
  // unenforced and not which. The `unevaluated` array in the JSON document already carried `rule`; the
2236
2299
  // human channel simply did not show it.
2237
2300
  for (const u of gunevaluated) console.error(`candor-ts: gate: ${u.why}`);
2238
- const g = loadGateReport(prefix);
2301
+ // ⟨0.33⟩ this gate's OWN rules ride along so `loadGateReport` can compare them against every gated
2302
+ // report's `scannedUnder` (SPEC §2 ⟨0.33⟩) — the ONE place this route computes that condition; see
2303
+ // `g.unaskedRules` below.
2304
+ const g = loadGateReport(prefix, gpol.deny);
2239
2305
  // ⟨0.32⟩ DOES THIS POLICY'S ANSWER DEPEND ON THE CODE THE PRODUCER LEFT UNREAD? — the ONE place that
2240
2306
  // condition is applied on this route (the rule itself is stated at the reading site in
2241
2307
  // query-core.mjs's `loadGateReport`). `deny` is the rule vector that carries `pure` too;
@@ -2367,7 +2433,14 @@ switch (cmd) {
2367
2433
  // ⟨0.32⟩ the THIRD cause, read off `excluded[].peeked` — code the PRODUCER never opened. Distinct from
2368
2434
  // the ⟨0.30⟩ one above: that is "the peek looked and found the effect you deny", this is "nothing
2369
2435
  // looked at all", and the two want different repairs.
2370
- const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0;
2436
+ // ⟨0.33⟩ the FOURTH cause — a class the producer's peek DID read, but under a deny set that does not
2437
+ // cover this gate's own. Distinct from `gunread` above: that is "nothing looked", this is "something
2438
+ // looked, for a narrower question than the one being asked now". Computed by `loadGateReport` from
2439
+ // the SAME `scannedUnder`/`excluded[].peeked` pair the gate route reads for its other two scope
2440
+ // causes, never re-derived here.
2441
+ const gunasked = g.unaskedRules ?? [];
2442
+ const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0
2443
+ || gunasked.length > 0;
2371
2444
  // The verdict document — the SAME builder shape scan.mjs writes, field for field and in the same key
2372
2445
  // order, because §3.1 ⟨0.24⟩ makes byte-equality with `scan --policy`'s `--gate-json` the acceptance
2373
2446
  // test. `analyzed.count`, `incomplete`/`unanalyzed` and the ⟨0.15⟩ coverage advisory all come off the
@@ -2425,15 +2498,22 @@ switch (cmd) {
2425
2498
  // ⟨0.30⟩ NAME THE CAUSE THAT ACTUALLY FIRED. Two causes reach this exit now, and a message that
2426
2499
  // always says "could not analyze" would report the wrong repair for the scope one — the operator
2427
2500
  // would go looking for a parse failure that is not there.
2428
- // ⟨0.32⟩ …and a THIRD, last in the same order scan.mjs uses (`unanalyzed` → `outOfScope` → unread),
2501
+ // ⟨0.32⟩ …and a THIRD, in the same order scan.mjs uses (`unanalyzed` → `outOfScope` → `unread`),
2429
2502
  // so a report that trips two of them names the same cause on both routes. Its message differs from
2430
2503
  // the `outOfScope` one because the repair does: that one wants a scan whose SELECTOR reaches the
2431
2504
  // code, this one wants a scan that was ASKED the question at all.
2505
+ // ⟨0.33⟩ …and a FOURTH, LAST, because each cause above names a MORE concrete gap than this one: a
2506
+ // class the producer's peek DID read, but under a deny set that does not cover this gate's own —
2507
+ // SPEC §2 ⟨0.33⟩. The remedy says "the SAME policy", not "a policy": the operator DID scan with a
2508
+ // policy, and reading ⟨0.32⟩'s remedy loosely (any policy re-scans it) is what produces this hole,
2509
+ // because the peek only ever looked for the PRODUCER's denied effects.
2432
2510
  const why = g.unanalyzed.length
2433
2511
  ? `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`
2434
2512
  : gscope.length
2435
2513
  ? `gate NOT certified — the report names ${gscope.length} function(s) OUTSIDE the scan's scope performing an effect this policy denies; the gate did not judge them, so the verdict is incomplete rather than a pass`
2436
- : `gate NOT certified — the report says the scan did not READ ${gunread.join(", ")}. Their effects are absent because nothing looked, not because there are none, so the verdict is INCOMPLETE rather than a pass. Re-scan the sources with this policy (candor-ts <dir> --policy <file>) — a scan that was never asked cannot certify what it never opened`;
2514
+ : gunread.length
2515
+ ? `gate NOT certified — the report says the scan did not READ ${gunread.join(", ")}. Their effects are absent because nothing looked, not because there are none, so the verdict is INCOMPLETE rather than a pass. Re-scan the sources with this policy (candor-ts <dir> --policy <file>) — a scan that was never asked cannot certify what it never opened`
2516
+ : `gate NOT certified — this report's peek was bounded by the deny set its producing scan held, and that set does not cover ${gunasked.length} rule(s) of this policy: ${gunasked.join(", ")}. The excluded files it reports as read were searched for OTHER effects, so an empty finding there is not an answer to this question, and the verdict is INCOMPLETE rather than a pass. Re-run the producing scan under THE SAME policy this gate is applying (candor-ts <dir> --policy <file> --json <report>) — not merely under a policy`;
2437
2517
  console.error(`candor-ts: ${why}`);
2438
2518
  // The INCOMPLETE verdict is a JUDGEMENT, not a refusal: it names what was analyzed and what was not,
2439
2519
  // and §3.1 makes byte-equality with `scan --policy`'s document the acceptance test for exactly it.
package/scan.mjs CHANGED
@@ -31,7 +31,7 @@ import os from "node:os";
31
31
  import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
32
32
  reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict, sortViolations,
33
33
  netClassResolver, resolveReasonClasses } from "./policy.mjs";
34
- import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
34
+ import { unverifiedHoleRule, ruleUpgrade, canonicalDenySet, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
35
35
  import { printAgents, writeStdoutSync } from "./contract.mjs";
36
36
  import { isTestPath, kappa, kappaKnows, nodeCoreUnreviewed, fsKind, commandHeadEffects, hostLiteral,
37
37
  tablesInSql, modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf,
@@ -46,7 +46,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
46
46
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
47
47
  // Reused, never re-littered.
48
48
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
49
- const SPEC_VERSION = "0.32";
49
+ const SPEC_VERSION = "0.33";
50
50
 
51
51
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
52
52
  //
@@ -1459,7 +1459,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1459
1459
  // This set used to hold `dist`, `build`, `out`, `coverage`, `.next` AND every dot-directory. Nothing
1460
1460
  // upstream skips those, so the two halves of one report disagreed about which files exist.
1461
1461
  //
1462
- // MEASURED at spec 0.32, byte-identical code, one policy (`deny Exec`), two directory names:
1462
+ // MEASURED (spec 0.32, informative), byte-identical code, one policy (`deny Exec`), two directory names:
1463
1463
  // lib/shipped.js → exit 2, `excluded: [{class: "outside-the-tsconfig-program", count: 1}]`,
1464
1464
  // `outOfScope: [{fn: "run", effects: ["Exec"]}]`
1465
1465
  // dist/shipped.js → exit 0, `policy ✓`, `excluded: []`
@@ -5060,7 +5060,7 @@ function visitCalls(node) {
5060
5060
  // `getResolvedSignature()` hands back the BASE's — which lives in the base's file, so both the
5061
5061
  // class NAME and the MODULE were read off the wrong declaration.
5062
5062
  //
5063
- // MEASURED at spec 0.32, `deny Fs`, with `fs.readFileSync` in the same file as a control:
5063
+ // MEASURED (spec 0.32, informative), `deny Fs`, with `fs.readFileSync` in the same file as a control:
5064
5064
  // readIt(p) { return new fs.ReadStream(p); } -> ABSENT from `functions`, no Unknown, exit 0
5065
5065
  // control(p) { return fs.readFileSync(p); } -> Fs, correctly
5066
5066
  // `fs.ReadStream` declares no constructor, so the signature resolved to `stream.Readable`'s in
@@ -7030,6 +7030,13 @@ const writeAtomic = (file, text) => writeSinkAtomic(file, text);
7030
7030
  // ABSENT, because nothing was asked and `[]` would be a claim. With a policy, only effects that policy
7031
7031
  // DENIES are reported — otherwise the noise floor is "everything you excluded".
7032
7032
  let outOfScopeFindings = null;
7033
+ // ⟨0.33⟩ THE QUESTION THIS PEEK WAS PUT — the canonical expanded deny/pure rules it is about to match
7034
+ // with. SPEC §2 ⟨0.33⟩ `scannedUnder.deny`: `outOfScopeFindings`/`excluded[].peeked` are true only
7035
+ // RELATIVE to this set, because the ⟨0.29⟩ bound above filters the peek to effects THIS policy denies —
7036
+ // so a consumer gating the report with a DIFFERENT deny set is answering a question nobody asked. Set
7037
+ // under the SAME condition as `outOfScopeFindings` (present iff the peek was attempted at all), never
7038
+ // re-derived on a second parse: this run's own `peekPolicy.deny` IS the matcher the peek used.
7039
+ let scannedUnderRules = null;
7033
7040
  // ⟨0.29⟩ DID THE PEEK ACTUALLY READ ANYTHING? `peeked` was a constant of the exclusion CLASS, so a peek
7034
7041
  // that never ran, could not spawn, or produced unparseable output still published `peeked: true` beside
7035
7042
  // `outOfScope: []` — byte-identical to a clean peek, and the ⟨0.26⟩ partial-manifest failure inside the
@@ -7074,6 +7081,12 @@ if (policyPath && excludedFiles.length) {
7074
7081
  // keys on THIS, not on whether the peek then succeeded; see the note there.
7075
7082
  peekAttempted = true;
7076
7083
  outOfScopeFindings = [];
7084
+ // ⟨0.33⟩ RECORDED HERE, ALONGSIDE `outOfScopeFindings`'s own assignment, and BEFORE any file is
7085
+ // opened — a policy whose only deny rule matches nothing under this tree still asked the question,
7086
+ // so `scannedUnder: {deny: [...]}` must read "asked-and-clear", not "never asked". `canonicalDenySet`
7087
+ // is the SAME renderer `ruleUpgrade` quotes back to an operator for the provable-purity upgrade, so
7088
+ // the string an operator reads and the string a gate compares cannot become two spellings of one rule.
7089
+ scannedUnderRules = canonicalDenySet(peekPolicy.deny);
7077
7090
  // ⟨0.31⟩ A FILE THIS ENGINE CANNOT READ CANNOT BE PEEKED, AND THE CLASS MUST SAY SO.
7078
7091
  //
7079
7092
  // MEASURED: an excluded file performing a denied `Fs`, with the policy `deny Fs`. Readable, the peek
@@ -7290,6 +7303,12 @@ if (NO_SOURCES && !(Array.isArray(outOfScopeFindings) && outOfScopeFindings.leng
7290
7303
  }
7291
7304
  if (outOfScopeFindings) {
7292
7305
  envelope.outOfScope = outOfScopeFindings;
7306
+ // ⟨0.33⟩ …and THE QUESTION IT WAS PUT, immediately after the answer it qualifies (SPEC §2 ⟨0.33⟩'s
7307
+ // position: "so a reader meets the two together"). Same condition as `outOfScope` above by
7308
+ // construction — both are set inside the identical peek-attempted branch — so a report carrying one
7309
+ // never lacks the other, and a pre-⟨0.33⟩ report (no policy, or one this run refused) stays
7310
+ // byte-identical because neither key exists.
7311
+ envelope.scannedUnder = { deny: scannedUnderRules };
7293
7312
  // SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
7294
7313
  // in the same breath that a file this scan did not judge holds the effect they denied.
7295
7314
  for (const f of outOfScopeFindings) {