fix(search): gate verify-on-read file results on vector-index tag membership

Verify-on-read only checked file *accessibility* (file_accessible_by_id),
never tag membership, so a file removed from the `vector-index` tag (but
still readable) kept surfacing in semantic search, and stale points only
got evicted when they happened to rank in a search's top-K.

Rework `_verify_files` to gate on current `vector-index` tag membership via
a single batch `find_files_by_tag(tag, mime_type_filter="application/pdf")`
REPORT per search (plus a one-shot EXCLUDED_TAGS lookup for exclusion-wins
parity) — exactly what the scanner indexes. A file is kept iff it is in that
set, so untagged / deleted / excluded files drop out immediately and the
existing eviction wiring reclaims their Qdrant points. The gate is strict
for all file results, own and shared. Mirrors the batch-fetch-and-intersect
shape of `_verify_news_items` (one semaphore slot, fail-open on fetch error,
malformed-id keep).

- Promote the tag name to a `vector_sync_pdf_tag` Settings field (dynaconf
  env mapping VECTOR_SYNC_PDF_TAG) used by both scanner and verifier;
  drop the scanner's direct os.getenv.
- Expose `find_files_by_tag` on NextcloudClientProtocol.
- Rewrite the file-verifier unit tests (tagged/untagged/deleted/excluded/
  fail-open/non-numeric); update the ACL + verify-on-read integration tests
  to seed tagged PDFs.
- Amend ADR-019 and the configuration.md verify-on-read latency budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-02 21:14:44 +02:00
co-authored by Claude Opus 4.8
parent 7e4b83dc94
commit d4dbf01b0a
9 changed files with 358 additions and 220 deletions
@@ -4,6 +4,22 @@
**Date**: 2026-05-01
**Depends On**: ADR-007 (Background Vector Sync), ADR-010 (Webhook-Based Vector Sync)
> **Update (2026-06-02) — tag-aware file verification.** The `file` verifier
> described below as a per-id WebDAV check (`PROPFIND`, later
> `file_accessible_by_id`) now gates on current **`vector-index` tag
> membership** instead. It issues a single
> `find_files_by_tag(<VECTOR_SYNC_PDF_TAG>, mime_type_filter="application/pdf")`
> REPORT per search (plus a one-shot `EXCLUDED_TAGS` lookup) and keeps only
> files in that set — i.e. exactly what the scanner indexes. This is the
> "fetch once and intersect" shape (like `news_item`), not per-id fan-out, and
> it closes a gap the original design missed: a file *removed from the tag* (as
> opposed to deleted/unshared) stayed accessible and so survived the old check,
> lingering in results until the scanner's grace-period sweep. **Decision:** the
> gate is strict for all file results, own and shared — a shared file survives
> only if the owner's (userVisible) tag surfaces in the *searcher's* tag REPORT
> (validated by `tests/integration/test_acl_shared_search.py`). See
> `docs/configuration.md` → "Verify-on-Read Latency Budget" for the cost.
## Context
The vector index in Qdrant is a *recall layer*, not the source of truth. Authoritative state for every indexed document — whether a note exists, whether a file is still shared with the user, whether a deck card is on a board the user can read — lives in Nextcloud, not in our index. Whenever those two views drift, semantic search returns **ghost records**: results that point to documents the user can no longer access (or that no longer exist at all).
+26 -10
View File
@@ -645,10 +645,12 @@ This adds Nextcloud round-trips to the search path that operators should be
aware of:
- **Per-search cost**: one Nextcloud round-trip per *unique* `(doc_id, doc_type)`
in the result set. Chunking means a 10-result page typically references 3-5
unique documents, so verification adds 3-5 round-trips. With the default
20-way concurrency this is one parallel batch — usually under 100 ms on a
healthy connection.
in the result set — except `file` and `news_item`, which each batch into a
single call per search regardless of how many results they contribute (see
the Files and News caveats below). Chunking means a 10-result page typically
references 3-5 unique documents, so verification adds 3-5 round-trips. With
the default 20-way concurrency this is one parallel batch — usually under
100 ms on a healthy connection.
- **Concurrency**: all verifications fan out under a shared semaphore.
Tunable via the `VERIFICATION_CONCURRENCY` env var (settings field
`verification_concurrency`, default 20) — lower it if your Nextcloud
@@ -664,14 +666,28 @@ aware of:
search that surfaces news results. Disabling News in the indexer or running
with a smaller backlog mitigates this; per-item paginated verification is
tracked as a future improvement.
- **Eviction**: when verification finds a definitive miss (404 / 403), the
corresponding Qdrant points are deleted in the background on a lifespan-owned
task group — fire-and-forget, does **not** block the search response.
Eviction failures are logged but never propagated; the next query will
re-verify and re-attempt (self-healing).
- **Files caveat**: `file` results are gated on current **`vector-index` tag
membership**, not bare access — the verifier issues a single
`find_files_by_tag(<tag>, mime_type_filter="application/pdf")` REPORT per
search that contains any file result (plus a one-shot `EXCLUDED_TAGS`
lookup), then keeps only files in that set. This matches exactly what the
scanner indexes, so a file removed from the tag (or deleted, or moved under
an excluded folder) drops out of results immediately rather than waiting for
the scanner sweep. The REPORT expands tagged folders via a `Depth: infinity`
SEARCH, so deployments that tag whole directory trees pay that walk once per
search; configure `VECTOR_SYNC_PDF_TAG` to change the tag name. **Shared
files**: a file an owner tagged and shared with the searcher only survives
verification if the owner's (userVisible) tag surfaces in the *searcher's*
tag REPORT.
- **Eviction**: when verification finds a definitive miss (a 404 / 403, or — for
files — absence from the tag set), the corresponding Qdrant points are deleted
in the background on a lifespan-owned task group — fire-and-forget, does
**not** block the search response. Eviction failures are logged but never
propagated; the next query will re-verify and re-attempt (self-healing).
- **Failure modes**: transient errors (5xx, network) keep results visible
(fail open) so a flaky link does not silently shrink result pages; only
*definitive* 404 / 403 drops them.
*definitive* misses (404 / 403, or a file no longer in the tag set) drop them.
If the file tag REPORT itself errors, all file results are kept (fail open).
If eviction ever needs to be disabled (debugging, benchmarking), the
`evict_on_missing=False` keyword argument on `verify_search_results()` skips