From 8edd2a91c7bdc187014b18cc8026ffc136976365 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:14:58 -0500 Subject: [PATCH] fix(graph): stop leaking edges to excluded neighbors find_neighbors() appended an edge to the response before checking whether its other endpoint passed the same retrievability/existence checks gating node inclusion (_neighbor_ok / _node_kind). superseded, archived, and redacted claims -- and missing nodes -- were correctly excluded from `nodes`, but the edge pointing at them still went out, so a response could contain an edge whose target referenced a claim id the response itself said didn't exist. kb.neighbors shares this code path across all three surfaces (mcp, jsonl, cli), so the leak was identical everywhere. reordered the loop so an edge is only recorded once its other endpoint has been accepted into `visited` (either just now, having passed the gate, or already accepted in an earlier iteration). --- CHANGELOG.md | 6 ++++++ src/vouch/graph.py | 35 +++++++++++++++++------------------ tests/test_graph.py | 4 ++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b81f33a..0a2733c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Fixed +- `graph.find_neighbors()` no longer leaks edges to excluded neighbors. + superseded/archived/redacted claims and missing nodes were already filtered + out of the returned `nodes` list, but the edge pointing at them was still + included -- `kb.neighbors` (mcp, jsonl, and cli surfaces all share this + code path) could hand back an edge whose `target` referenced a claim id + the response itself said didn't exist. - `lifecycle.contradict()` no longer lets a claim contradict itself. calling it with the same claim id on both sides previously wrote a self-loop `contradicts` reference and flipped the claim to `contested` with no diff --git a/src/vouch/graph.py b/src/vouch/graph.py index e05e7c1c..b941af2e 100644 --- a/src/vouch/graph.py +++ b/src/vouch/graph.py @@ -176,6 +176,23 @@ def find_neighbors( for current in frontier: for edge in _edges_from_node(store, current, rel_types=rel_filter): other = edge.target if edge.source == current else edge.source + if other not in visited: + try: + kind = _node_kind(store, other) + except ArtifactNotFoundError: + continue + if not _neighbor_ok(store, other, kind): + continue + visited.add(other) + next_frontier.append(other) + nodes.append({ + "id": other, + "kind": kind, + "distance": dist, + "via": current, + "relation": edge.relation, + "summary": _summary_for(store, kind, other), + }) ekey = (edge.source, edge.target, edge.relation) if ekey not in seen_edges: seen_edges.add(ekey) @@ -185,24 +202,6 @@ def find_neighbors( "relation": edge.relation, "relation_id": edge.relation_id, }) - if other in visited: - continue - try: - kind = _node_kind(store, other) - except ArtifactNotFoundError: - continue - if not _neighbor_ok(store, other, kind): - continue - visited.add(other) - next_frontier.append(other) - nodes.append({ - "id": other, - "kind": kind, - "distance": dist, - "via": current, - "relation": edge.relation, - "summary": _summary_for(store, kind, other), - }) if len(nodes) >= max_nodes: break if len(nodes) >= max_nodes: diff --git a/tests/test_graph.py b/tests/test_graph.py index 8130dc5e..0d7741b6 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -93,6 +93,10 @@ def test_find_neighbors_excludes_superseded_claims(store: KBStore) -> None: result = graph.find_neighbors(store, "new", depth=1) assert {n["id"] for n in result["nodes"]} == set() assert "old" not in {n["id"] for n in result["nodes"]} + # the edge to the excluded neighbor must not leak either -- a client + # that trusts `nodes` as the visible set would otherwise see a "supersedes" + # edge pointing at a claim id it was never told exists. + assert result["edges"] == [] def test_find_neighbors_unknown_node_raises(store: KBStore) -> None: