From 665bc0808e5dd5e72ed1bc5b4bfad6338119a96e Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Fri, 31 Jul 2026 00:36:16 -0700 Subject: [PATCH 1/3] 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 gate that decides 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. reorder the loop so an edge is only recorded once its other endpoint has been accepted into visited - either already, having passed the gate in an earlier iteration, or just now. extended test_find_neighbors_excludes_superseded_claims to also assert edges == [] - fails on the old code (returns the dangling edge to the superseded claim via the SUPERSEDES relation lifecycle.supersede() creates), passes with the fix. Fixes #716 --- CHANGELOG.md | 12 ++++++++++++ src/vouch/graph.py | 41 +++++++++++++++++++++++------------------ tests/test_graph.py | 3 +++ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed8832f..e04bcda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,18 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`kb.neighbors` no longer leaks edges pointing at excluded nodes** + (#716): `find_neighbors` appended an edge to the response before + checking whether its other endpoint passed the same + retrievability/existence gate that decides 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 an 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. an edge is now + only recorded once its other endpoint has been accepted into the + visited set — either already, or just now by passing the same gate. - **`reset()`/`deindex()` now clear the legacy `embeddings` table too** (#543 reopened, root-caused): both functions' own docstrings promise to remove every embedding row for a reindex or a deleted artifact, but diff --git a/src/vouch/graph.py b/src/vouch/graph.py index e05e7c1c..51b50031 100644 --- a/src/vouch/graph.py +++ b/src/vouch/graph.py @@ -176,6 +176,29 @@ 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 + # Only record the edge once its other endpoint has been + # accepted into `visited` - either already, or just now by + # passing the same existence/retrievability gate that decides + # node inclusion below. Recording it unconditionally leaked an + # edge pointing at a node the response itself excluded (a + # superseded/archived/redacted claim, or a missing one). + 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 +208,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..ccb2ec33 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -93,6 +93,9 @@ 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 SUPERSEDES relation lifecycle.supersede() creates must not leak as + # a dangling edge to a node the response itself excluded. + assert result["edges"] == [] def test_find_neighbors_unknown_node_raises(store: KBStore) -> None: From 98c214d1849b97f3daf91d21cc54edc783d4d4e6 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Fri, 31 Jul 2026 01:00:05 -0700 Subject: [PATCH 2/3] fix(capture): restore coerce_numeric on min_observations/dedup_window a6c6862 (#686) fixed capture.load_config's min_observations and dedup_window_seconds to fall back to their defaults on a malformed config value via the coerce_numeric() helper, instead of raising ValueError straight out of load_config. 47eaf56 (#645, realtime opt-in) branched off the pre-fix capture.py and reintroduced the bare int()/float() calls when it merged into test - the coerce_numeric import survived (nothing else referenced it), but the two call sites it fed didn't, silently reverting the fix and breaking ruff's unused-import gate for every PR built on top of `test`. restore the coerce_numeric() calls, matching recall.load_config's still-intact equivalent. unrelated to this PR's own change (graph.py edge leak); needed only to get CI green on top of a currently-broken `test`. --- src/vouch/capture.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 9aeb98b8..ec99b654 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -75,9 +75,15 @@ def load_config(store: KBStore) -> CaptureConfig: return CaptureConfig( enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED), realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME), - min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)), - dedup_window_seconds=float( - raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) + min_observations=coerce_numeric( + raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS), + DEFAULT_MIN_OBSERVATIONS, + int, + ), + dedup_window_seconds=coerce_numeric( + raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS), + DEFAULT_DEDUP_WINDOW_SECONDS, + float, ), answer_mode=answer_mode, ) From abe3bf1a0a55e6e557f04f142c99d9c1be8356a3 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Fri, 31 Jul 2026 04:47:01 -0700 Subject: [PATCH 3/3] test(graph): cover the missing-neighbor edge-exclusion branch the diff-coverage gate flagged lines 188-189 of graph.py (the ArtifactNotFoundError branch in find_neighbors, hit when a relation's other endpoint no longer exists) as untested - the existing excludes_superseded_claims test only exercises the sibling _neighbor_ok exclusion path, not the missing-node one. add a case where a relation's target entity is deleted after the relation was created (storage doesn't cascade-delete relations), leaving a dangling reference. asserts both nodes and edges stay empty, matching the fix - confirmed it fails against the pre-fix graph.py (returns the dangling edge) via a throwaway checkout of the prior commit. --- tests/test_graph.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_graph.py b/tests/test_graph.py index ccb2ec33..b6486f77 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -98,6 +98,21 @@ def test_find_neighbors_excludes_superseded_claims(store: KBStore) -> None: assert result["edges"] == [] +def test_find_neighbors_excludes_edge_to_missing_neighbor(store: KBStore) -> None: + """A relation left dangling after its target artifact was deleted (no + cascade delete) must not leak as an edge either - the same exclusion + `_node_kind`'s ArtifactNotFoundError already applies to `nodes`.""" + store.put_entity(Entity(id="a", name="A", type=EntityType.CONCEPT)) + store.put_entity(Entity(id="b", name="B", type=EntityType.CONCEPT)) + store.put_relation(Relation( + id="a-b", source="a", relation=RelationType.USES, target="b", + )) + store._entity_path("b").unlink() + result = graph.find_neighbors(store, "a", depth=1) + assert result["nodes"] == [] + assert result["edges"] == [] + + def test_find_neighbors_unknown_node_raises(store: KBStore) -> None: with pytest.raises(ArtifactNotFoundError): graph.find_neighbors(store, "missing", depth=1)