From a2700744ea2b2295714f4a99f2fce190e8da6796 Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 21:45:23 +0800 Subject: [PATCH] fix(explain): withhold the summary of a scope-filtered candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the candidate list is built from every hit the pipeline saw, and summaries were keyed off that same pre-scope fused set. so a viewer scoped to one project got back the text of claims `kb.search` and `kb.context` withhold from it — the module docstring promises the opposite, and the surface is exposed over mcp, jsonl and the cli. listing the candidate is deliberate: naming the gate that hid it is what makes the report useful for tuning, and `test_scope_filtered_candidate_is_attributed_to_scope` pins that. so this keeps the row and its `scope-filtered` gate and withholds only the text, by sourcing summaries from the scoped set rather than the fused one. a candidate the viewer can retrieve is unaffected. --- CHANGELOG.md | 7 +++++++ src/vouch/explain_ranking.py | 12 +++++++++--- tests/test_explain_ranking.py | 27 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81fda0ad..21f9f4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ All notable changes to vouch are documented here. Format follows `ARCHIVED` page with `followup_status=open` and a past `due_at` still appeared every morning — stale claims were filtered, pages were not. mirror recall: archived followups leave the due list. +- **`kb.explain_ranking` no longer returns the summary of a scope-filtered + candidate**: the report listed every candidate the pipeline saw, sourcing + summaries from the pre-scope fused set, so a viewer got back the claim text + of artifacts `kb.search` and `kb.context` withhold for that same viewer — the + opposite of the module's stated scoping invariant. the candidate is still + listed with its `scope-filtered` gate, since naming the gate that hid it is + the point of the report; only the summary is withheld. - **`verify_all` / `doctor` treat missing externals like drift** (#622): `vouch source verify` already marked `external_status=missing` as `!`, but `verify_all`'s audit `failed` list and `health.doctor` only looked diff --git a/src/vouch/explain_ranking.py b/src/vouch/explain_ranking.py index 2ec7871b..eb1ddd9b 100644 --- a/src/vouch/explain_ranking.py +++ b/src/vouch/explain_ranking.py @@ -33,8 +33,10 @@ Read-only by construction: every helper called here is one the read path already uses, and nothing writes, proposes, or mutates the KB. Viewer scoping -runs through the same ``filter_hits`` as ``kb.context``, so a caller cannot -see a candidate it could not already retrieve. +runs through the same ``filter_hits`` as ``kb.context``: a candidate the +viewer cannot retrieve is still listed, because naming the gate that hid it is +the point of the report, but it is listed without its summary — the content +``kb.search`` and ``kb.context`` withhold for that viewer is withheld here too. """ from __future__ import annotations @@ -281,8 +283,12 @@ def explain_ranking( # Every candidate the pipeline ever saw, in the order fusion produced them, # so a dropped artifact is still explained rather than silently missing. + # Summaries come from the *scoped* set: a candidate the viewer cannot + # retrieve keeps its gate attribution — that is the point of the report — + # but not its text. `kb.search` and `kb.context` withhold that summary for + # this viewer, so this surface must not hand it back. candidates: list[dict[str, Any]] = [] - summaries = {_key(h): h[2] for h in hits} + summaries = {_key(h): h[2] for h in scoped} for hit in hits: key = _key(hit) rows, gate = _stage_rows(key, snapshots) diff --git a/tests/test_explain_ranking.py b/tests/test_explain_ranking.py index eba2842a..d0e3b7bd 100644 --- a/tests/test_explain_ranking.py +++ b/tests/test_explain_ranking.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import sqlite3 from pathlib import Path @@ -137,6 +138,32 @@ def test_scope_filtered_candidate_is_attributed_to_scope(store: KBStore) -> None assert _stages(cand) == ["hybrid"] +def test_scope_filtered_candidate_does_not_carry_its_summary(store: KBStore) -> None: + """The gate that hid a candidate is reported; the text behind it is not. + + `kb.search` returns nothing for this viewer, so the same viewer asking + `kb.explain_ranking` must not get the claim text back through the + candidate's summary. + """ + secret = "jwt signing secret is hunter2-example" + store.put_claim(Claim( + id="c-priv", text=secret, + evidence=[store.list_sources()[0].id], + scope=ArtifactScope(visibility=Visibility.PRIVATE, project="other-project"), + )) + health.rebuild_index(store) + + result = er.explain_ranking(store, query="jwt", limit=5, project="this-project") + cand = _by_id(result)["c-priv"] + + assert cand["gate"] == "scope-filtered" + assert cand["summary"] == "" + assert "hunter2-example" not in json.dumps(result) + + # a candidate the viewer *can* retrieve still carries its summary + assert _by_id(result)["c1"]["summary"] + + def test_require_citations_names_the_uncited_claim(store: KBStore) -> None: """The uncited gate renames the responsible candidate, it does not drop it.""" result = er.explain_ranking(