feat(provider): validate scope_filter against the v0.5 4-axis lattice

bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.

Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
This commit is contained in:
vh
2026-06-16 01:24:01 -07:00
parent ca02c70b7c
commit aac4353933
5 changed files with 46 additions and 9 deletions
@@ -209,9 +209,10 @@ FN search(self, vector: list[float], *, top_k: int, scope_filter: dict | None =
BRIEF: Vector (cosine) recall over sqlite-vec, scoped, returning the top_k IN-SCOPE chunks.
PRE: [PRE-001 hard] len(vector) == embedding_dim -- else InvalidArguments
PRE: [PRE-002 hard] metadata_filter is empty/None -- v1 advertises no filterable fields; a non-empty filter → InvalidArguments
PRE: [PRE-003 hard] every scope_filter axis ∈ {end_user, group, tenant, agent_self} -- else InvalidFilter (memory.invalid_filter 400); the bifrost wire-v0.5 lattice, matching the reference _validate_scope_filter (#10 made agent_self canonical)
POST: [POST-001 return_value] returns the top_k highest-cosine records WHOSE scope matches scope_filter — at most top_k, and never fewer than min(top_k, in-scope count) (INV-005). Each: {chunk (verbatim), chunk_id, score, recalled_view (= chunk["distillate"] or chunk), revision} -- assert
STEPS:
1. [setup] validate scope_filter is a flat {axis: value} dict (matched against record["scope"][axis])
1. [setup] validate scope_filter is a flat {axis: value} dict (matched against record["scope"][axis]) AND every axis ∈ the v0.5 lattice {end_user, group, tenant, agent_self} (else InvalidFilter)
2. [sequential, flexibility=indicative] rank candidates by cosine over record["embedding"]; KEEP only scope-matching records (INV-005); THEN take top_k — so top_k counts IN-SCOPE hits, not pre-filter hits (over-fetch from the vec index or post-filter rank as needed)
3. [cleanup] RETURN result rows (chunk verbatim + score + recalled_view + revision)
TESTS:
@@ -219,6 +220,7 @@ TESTS:
scope_isolation [adversarial]: two scopes, search one → never returns the other's chunk, and returns top_k of the IN-SCOPE set even if out-of-scope chunks score higher (INV-005)
empty [boundary]: search empty store → []
metadata_filter_rejected [adversarial]: non-empty metadata_filter → InvalidArguments
lattice_axes [adversarial]: out-of-lattice scope axis → InvalidFilter; agent_self admitted (wire v0.5, #10)
parity_vs_reference [scenario]: identical search envelopes vs InMemoryMemoryStore → same ranked chunk_ids/shape (#195)
```