Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/vouch/explain_ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_explain_ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import sqlite3
from pathlib import Path

Expand Down Expand Up @@ -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(
Expand Down
Loading