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: