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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
41 changes: 23 additions & 18 deletions src/vouch/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ 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_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:
Expand Down
Loading