feat(search): verify-on-read for semantic search results (ADR-019)

The vector index lags Nextcloud (5-min webhook cron + scanner interval),
producing ghost records for deleted/unshared documents until the next
reconciliation. Verify each unique document against Nextcloud at query
time, drop inaccessible results, and lazily evict the corresponding
Qdrant points.

Per-doc_type batch verifiers: notes/files/deck cards run concurrently
per id; news items use a single fetch + intersect to avoid the per-item
fetch-all amplification. Transient errors fail open (keep result, log
warning) — only definitive 4xx drops. Multiple chunks of the same doc
collapse to one verification call.

Wired into nc_semantic_search before the limit trim and before context
expansion. nc_semantic_search_answer's per-note re-fetch retained as a
sub-second race guard since verification now happens upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 07:45:01 +02:00
co-authored by Claude Opus 4.7
parent 0c2d3e1086
commit d90e793d19
8 changed files with 1379 additions and 24 deletions
+4 -2
View File
@@ -71,8 +71,10 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
"""
Execute hybrid search using dense + sparse vectors with native RRF fusion.
Returns unverified results from Qdrant. Access verification should be
performed separately at the final output stage using verify_search_results().
Returns unverified results from Qdrant. Access verification is
performed separately at the server tool layer via
``nextcloud_mcp_server.search.verification.verify_search_results``
(see ADR-019).
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
to show multiple chunks from the same document while avoiding duplicate chunks.
+4 -2
View File
@@ -48,8 +48,10 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
) -> list[SearchResult]:
"""Execute semantic search using vector similarity.
Returns unverified results from Qdrant. Access verification should be
performed separately at the final output stage using verify_search_results().
Returns unverified results from Qdrant. Access verification is
performed separately at the server tool layer via
``nextcloud_mcp_server.search.verification.verify_search_results``
(see ADR-019).
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
to show multiple chunks from the same document while avoiding duplicate chunks.
+438
View File
@@ -0,0 +1,438 @@
"""Verify-on-read access checks for semantic search results (ADR-019).
The vector index is a recall layer; Nextcloud is the source of truth for
access. This module filters search results by checking each unique document
against Nextcloud at query time, dropping any that the user can no longer
access (deleted, unshared, etc.) and lazily evicting them from the index.
Per-doc_type verifiers are registered in ``_VERIFIERS``. Each takes the
authenticated client, a list of doc_ids, and the user_id, and returns the
subset of doc_ids that are currently accessible. The dispatch deliberately
groups by doc_type so doc-types with cheap batch endpoints (news_item) can
do a single fetch rather than one round-trip per result.
Failure policy:
- Definitive 403/404 from Nextcloud → drop the result and schedule eviction.
- Transient errors (5xx, network blips, unexpected exceptions) → keep the
result and log a warning. We never silently shrink result sets due to
flakes; the next query will re-verify.
- Unsupported doc_type (no registered verifier) → keep the result and log a
warning. Verification is opt-in per type; a missing verifier is a soft
failure, not a search failure.
"""
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
from httpx import HTTPStatusError
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.search.algorithms import SearchResult
from nextcloud_mcp_server.vector.eviction import delete_document_points
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
BatchVerifier = Callable[[Any, list[int | str], str], Awaitable[set[int | str]]]
"""(client, doc_ids, user_id) -> set of accessible doc_ids."""
# ---------------------------------------------------------------------------
# Per-doc-type verifiers
# ---------------------------------------------------------------------------
def _is_definitive_404_or_403(exc: BaseException) -> bool:
"""Return True if exc indicates the document is definitively inaccessible."""
if isinstance(exc, HTTPStatusError):
return exc.response.status_code in (403, 404)
return False
async def _verify_notes(
client: Any, doc_ids: list[int | str], user_id: str
) -> set[int | str]:
accessible: set[int | str] = set()
async def check(doc_id: int | str) -> None:
try:
await client.notes.get_note(int(doc_id))
accessible.add(doc_id)
except HTTPStatusError as e:
if _is_definitive_404_or_403(e):
return
logger.warning(
"Transient error verifying note %s: %s %s; keeping result",
doc_id,
e.response.status_code,
e,
)
accessible.add(doc_id)
except Exception as e:
logger.warning(
"Unexpected error verifying note %s: %s; keeping result",
doc_id,
e,
)
accessible.add(doc_id)
async with anyio.create_task_group() as tg:
for doc_id in doc_ids:
tg.start_soon(check, doc_id)
return accessible
async def _verify_files(
client: Any, doc_ids: list[int | str], user_id: str
) -> set[int | str]:
accessible: set[int | str] = set()
async def check(doc_id: int | str) -> None:
# Resolve file_id → file_path from Qdrant payload
file_path = await _resolve_file_path(user_id, doc_id)
if file_path is None:
# Cannot verify without a path; treat as accessible to avoid
# silently dropping legitimate results when payload is missing
logger.warning(
"No file_path in Qdrant for file_id %s; keeping result "
"(verification skipped)",
doc_id,
)
accessible.add(doc_id)
return
try:
info = await client.webdav.get_file_info(file_path)
if info is None:
# get_file_info returns None on definitive 404
return
accessible.add(doc_id)
except HTTPStatusError as e:
if _is_definitive_404_or_403(e):
return
logger.warning(
"Transient error verifying file %s (%s): %s %s; keeping result",
doc_id,
file_path,
e.response.status_code,
e,
)
accessible.add(doc_id)
except Exception as e:
logger.warning(
"Unexpected error verifying file %s (%s): %s; keeping result",
doc_id,
file_path,
e,
)
accessible.add(doc_id)
async with anyio.create_task_group() as tg:
for doc_id in doc_ids:
tg.start_soon(check, doc_id)
return accessible
async def _verify_deck_cards(
client: Any, doc_ids: list[int | str], user_id: str
) -> set[int | str]:
accessible: set[int | str] = set()
async def check(doc_id: int | str) -> None:
# Resolve card_id → (board_id, stack_id) from Qdrant payload
meta = await _resolve_deck_metadata(user_id, int(doc_id))
if meta is None:
# Without metadata we cannot run the cheap fast-path. Per ADR-019
# we deliberately do NOT fall back to O(boards × stacks) iteration
# in the search hot path; treat as accessible.
logger.warning(
"No deck metadata in Qdrant for card %s; keeping result "
"(verification skipped, legacy data without board_id/stack_id)",
doc_id,
)
accessible.add(doc_id)
return
try:
await client.deck.get_card(
board_id=meta["board_id"],
stack_id=meta["stack_id"],
card_id=int(doc_id),
)
accessible.add(doc_id)
except HTTPStatusError as e:
if _is_definitive_404_or_403(e):
return
logger.warning(
"Transient error verifying deck card %s: %s %s; keeping result",
doc_id,
e.response.status_code,
e,
)
accessible.add(doc_id)
except Exception as e:
logger.warning(
"Unexpected error verifying deck card %s: %s; keeping result",
doc_id,
e,
)
accessible.add(doc_id)
async with anyio.create_task_group() as tg:
for doc_id in doc_ids:
tg.start_soon(check, doc_id)
return accessible
async def _verify_news_items(
client: Any, doc_ids: list[int | str], user_id: str
) -> set[int | str]:
"""Batch-verify news items with a single fetch.
The Nextcloud News API has no per-item endpoint, so ``news.get_item`` is
implemented as a fetch-all + filter — which would be O(N × all_items) if
called per id. Instead we fetch once and intersect.
"""
requested = {int(d) for d in doc_ids}
try:
items = await client.news.get_items(batch_size=-1, get_read=True)
except HTTPStatusError as e:
# If the News API itself is gone (app disabled, user lost access),
# treat *all* requested items as inaccessible. Eviction will reclaim.
if _is_definitive_404_or_403(e):
logger.info(
"News API returned %s for user %s; treating all %d news_items as inaccessible",
e.response.status_code,
user_id,
len(requested),
)
return set()
logger.warning(
"Transient error fetching news items for verification: %s %s; keeping all results",
e.response.status_code,
e,
)
return set(doc_ids)
except Exception as e:
logger.warning(
"Unexpected error fetching news items for verification: %s; keeping all results",
e,
)
return set(doc_ids)
present_ids = {int(item.get("id")) for item in items if item.get("id") is not None}
# Map back to the original doc_id types (the caller may pass ints or strs)
accessible: set[int | str] = set()
for d in doc_ids:
if int(d) in present_ids and int(d) in requested:
accessible.add(d)
return accessible
_VERIFIERS: dict[str, BatchVerifier] = {
"note": _verify_notes,
"file": _verify_files,
"deck_card": _verify_deck_cards,
"news_item": _verify_news_items,
}
def get_supported_doc_types() -> set[str]:
"""Return the set of doc_types that have registered verifiers.
Used by CI guards and tests to ensure every indexed doc_type has a
verifier (see ADR-019 implementation checklist).
"""
return set(_VERIFIERS.keys())
# ---------------------------------------------------------------------------
# Qdrant payload lookup helpers
# ---------------------------------------------------------------------------
async def _resolve_file_path(user_id: str, doc_id: int | str) -> str | None:
"""Look up file_path for a file_id from any chunk's Qdrant payload."""
try:
qdrant_client = await get_qdrant_client()
settings = get_settings()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
FieldCondition(key="doc_type", match=MatchValue(value="file")),
]
),
limit=1,
with_payload=["file_path"],
with_vectors=False,
)
if scroll_result[0]:
point = scroll_result[0][0]
file_path = point.payload.get("file_path") if point.payload else None
if file_path:
return str(file_path)
return None
except Exception as e:
logger.debug("Error resolving file_path for file_id %s: %s", doc_id, e)
return None
async def _resolve_deck_metadata(user_id: str, card_id: int) -> dict[str, int] | None:
"""Look up (board_id, stack_id) for a deck card from any chunk's payload."""
try:
qdrant_client = await get_qdrant_client()
settings = get_settings()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
limit=1,
with_payload=["board_id", "stack_id"],
with_vectors=False,
)
if scroll_result[0]:
point = scroll_result[0][0]
payload = point.payload or {}
board_id = payload.get("board_id")
stack_id = payload.get("stack_id")
if board_id is not None and stack_id is not None:
return {"board_id": int(board_id), "stack_id": int(stack_id)}
return None
except Exception as e:
logger.debug("Error resolving deck metadata for card %s: %s", card_id, e)
return None
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
async def verify_search_results(
client: Any,
results: list[SearchResult],
*,
evict_on_missing: bool = True,
) -> list[SearchResult]:
"""Filter search results to those the user can currently access.
Deduplicates by ``(doc_id, doc_type)`` before verifying, so multiple
chunks from the same document cost a single check. Verifiers run
concurrently per doc_type (and within each doc_type, per id where that
is cheaper than batching).
When ``evict_on_missing=True``, points for documents that fail
verification are deleted from Qdrant in-line. Eviction failures are
logged but never propagated.
Args:
client: Authenticated NextcloudClient (must expose ``username``).
results: SearchResult list from the algorithm layer (may include
multiple chunks per document).
evict_on_missing: Schedule lazy eviction for inaccessible docs.
Returns:
Filtered list preserving the original order.
"""
if not results:
return results
user_id: str = client.username
# Group unique (doc_id, doc_type) by doc_type so each verifier sees a
# deduplicated batch.
by_type: dict[str, set[int | str]] = {}
for r in results:
by_type.setdefault(r.doc_type, set()).add(r.id)
# Run all type verifiers concurrently. Per-id failures are absorbed
# inside each verifier; this outer task group only fans out per type.
accessible_by_type: dict[str, set[int | str]] = {}
async def run_verifier(doc_type: str, doc_ids: set[int | str]) -> None:
verifier = _VERIFIERS.get(doc_type)
if verifier is None:
logger.warning(
"No verifier registered for doc_type=%r; keeping %d result(s) unverified",
doc_type,
len(doc_ids),
)
accessible_by_type[doc_type] = doc_ids
return
try:
accessible_by_type[doc_type] = await verifier(
client, list(doc_ids), user_id
)
except Exception as e:
# Verifier itself blew up (not per-id) — fail open.
logger.error(
"Verifier for doc_type=%s raised: %s; keeping all %d result(s) unverified",
doc_type,
e,
len(doc_ids),
exc_info=True,
)
accessible_by_type[doc_type] = doc_ids
async with anyio.create_task_group() as tg:
for doc_type, doc_ids in by_type.items():
tg.start_soon(run_verifier, doc_type, doc_ids)
# Compute (doc_id, doc_type) pairs that failed verification
inaccessible: set[tuple[int | str, str]] = set()
for doc_type, doc_ids in by_type.items():
accessible = accessible_by_type.get(doc_type, doc_ids)
for doc_id in doc_ids:
if doc_id not in accessible:
inaccessible.add((doc_id, doc_type))
if inaccessible:
logger.info(
"Verification dropped %d inaccessible document(s): %s",
len(inaccessible),
sorted((str(d), t) for d, t in inaccessible),
)
# Filter results in-place-style, preserving order
kept = [r for r in results if (r.id, r.doc_type) not in inaccessible]
# Lazy eviction — fire and forget, but bounded inline so we don't lose
# the user_id binding by escaping the task group.
if evict_on_missing and inaccessible:
async def evict(doc_id: int | str, doc_type: str) -> None:
try:
await delete_document_points(doc_id, doc_type, user_id)
except Exception as e:
logger.warning(
"Failed to evict %s_%s from Qdrant: %s", doc_type, doc_id, e
)
async with anyio.create_task_group() as tg:
for doc_id, doc_type in inaccessible:
tg.start_soon(evict, doc_id, doc_type)
return kept
+22 -20
View File
@@ -32,6 +32,7 @@ from nextcloud_mcp_server.observability.metrics import (
)
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.search.verification import verify_search_results
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -144,16 +145,15 @@ def configure_semantic_tools(mcp: FastMCP):
# Sort combined results by score
all_results.sort(key=lambda r: r.score, reverse=True)
# Note: BM25HybridSearchAlgorithm already deduplicates at chunk level
# (doc_id, doc_type, chunk_start, chunk_end), which allows multiple
# chunks from the same document while preventing duplicate chunks.
# No additional deduplication needed here - multiple chunks per document
# are valuable for RAG contexts.
# Qdrant already filters by user_id for multi-tenant isolation.
# Sampling tool will verify access when fetching full content.
search_results = all_results[
:limit
] # Final limit after chunk-level dedup in algorithm
# ADR-019: Verify-on-read. The vector index is a recall layer;
# Nextcloud is the source of truth for access. Filter out ghost
# records (deleted/unshared docs not yet reconciled by webhooks)
# BEFORE trimming to `limit`, so we don't lose accessible results
# to the limit slot that ghosts would otherwise occupy. We also
# run this BEFORE context expansion to avoid re-fetching docs that
# are about to be dropped.
verified_results = await verify_search_results(client, all_results)
search_results = verified_results[:limit]
# Convert SearchResult objects to SemanticSearchResult for response
results = []
@@ -414,9 +414,11 @@ def configure_semantic_tools(mcp: FastMCP):
success=True,
)
# 4. Fetch full content for notes in parallel (also verifies access)
# Use anyio task group for concurrent fetching with semaphore to prevent
# connection pool exhaustion
# 4. Fetch full content for notes in parallel.
# Access verification has already happened upstream in
# nc_semantic_search via verify_search_results (ADR-019), so any
# exception here is a sub-second race (doc deleted between
# verification and this fetch) — drop the result in that case.
client = await get_client(ctx)
accessible_results = [None] * len(search_response.results)
full_contents = [None] * len(search_response.results)
@@ -431,7 +433,6 @@ def configure_semantic_tools(mcp: FastMCP):
if result.doc_type == "note":
try:
note = await client.notes.get_note(result.id)
# Note is accessible, store result and full content
content = note.get("content", "")
accessible_results[index] = result
full_contents[index] = content
@@ -440,15 +441,16 @@ def configure_semantic_tools(mcp: FastMCP):
f"(length: {len(content)} chars)"
)
except Exception as e:
# Note might have been deleted or permissions changed
# Leave as None to filter out later
# Race window after verify_search_results — drop result.
logger.debug(
f"Note {result.id} not accessible: {e}. "
f"Excluding from results."
"Note %s disappeared between verification and "
"content fetch: %s. Excluding from results.",
result.id,
e,
)
else:
# Non-note document types (future: calendar, deck, files)
# For now, keep them with excerpts
# Non-note types (file, news_item, deck_card) keep the
# excerpt — already access-verified upstream.
accessible_results[index] = result
# full_contents[index] remains None (will use excerpt)
+61
View File
@@ -0,0 +1,61 @@
"""Lazy eviction of stale documents from the vector index.
Used by the verify-on-read path (ADR-019) to remove points for documents that
have been deleted or unshared in Nextcloud but not yet reconciled by the
webhook/scanner sync loop. Eviction is fire-and-forget from the search hot
path; failures are logged but never propagated, since the next query will
simply re-verify and re-attempt.
"""
import logging
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
async def delete_document_points(
doc_id: str | int,
doc_type: str,
user_id: str,
) -> None:
"""Remove all Qdrant points for a single document.
Deletes both real chunk points and any leftover placeholder points for the
given (user_id, doc_id, doc_type) tuple. Safe to call when the document is
not present — Qdrant returns successfully with zero points affected.
Args:
doc_id: Document ID (int for notes/files/cards/news, str otherwise)
doc_type: Document type (note, file, deck_card, news_item)
user_id: Owner of the points being evicted
Raises:
Exception: If the underlying Qdrant client raises. Callers in the
search hot path should catch and log; eviction failures must not
block search responses.
"""
qdrant_client = await get_qdrant_client()
settings = get_settings()
await qdrant_client.delete(
collection_name=settings.get_collection_name(),
points_selector=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
]
),
)
logger.info(
"Evicted Qdrant points for %s_%s (user=%s); "
"document was inaccessible at verification time",
doc_type,
doc_id,
user_id,
)