Skip to content

feat(w2): capability-label materialization, Redis cache, pgvector retrieval#22

Merged
geminimir merged 1 commit into
mainfrom
w2/retrieval-index
Jul 16, 2026
Merged

feat(w2): capability-label materialization, Redis cache, pgvector retrieval#22
geminimir merged 1 commit into
mainfrom
w2/retrieval-index

Conversation

@geminimir

Copy link
Copy Markdown
Owner

Summary

Milestone W2 — capability-label materialization + pgvector retrieval + Redis-cached labels. Closes #7, #8, #9.

The W2 acceptance gate is the superset invariant: LabelFilter(u) ⊇ Authorized(u). If it ever fails, Warden has a silent recall bug (an authorized doc that the pre-filter would never surface to Gate 2). Verified across 5,000 randomly generated authorization graphs per CI run.

What ships

  • core/labels.py — stable 31-bit label encoding, materialize_doc_labels (doc-side forward walk), labels_for (principal-side reverse walk), LabelCache Protocol with InMemoryCache + RedisCache. Asymmetric invalidation is documented at the top of the file and its correctness is exercised by the fixture tests: grants invalidate eagerly, revocations rely on TTL.
  • sql/002_documents.sqldocuments with int[] acl_labels, int[] barrier_tags, vector(768) embedding, LIST-partitioned by org_id. GIN + HNSW per partition.
  • retrieval/index.py — idempotent ensure_org_partition + upsert_document.
  • retrieval/strategies.py — three filtered-ANN strategies (exact, iterative, partitioned) behind an enum. All three verified to return correct results; recall-vs-selectivity plotting is a W4 concern per the milestone split.
  • New differential property testtest_label_filter_is_permissive_superset. This is the acceptance gate.
  • Integration tests — 10 against real pgvector, 4 against real Redis, plus the 7 from W1 still green.

Bug caught while wiring

Postgres partition DDL (PARTITION OF documents FOR VALUES IN (%s)) doesn't accept bind parameters. Fixed by validating org_id against [A-Za-z0-9_]+ and interpolating as a SQL literal. Injection surface is bounded by the same charset that the partition table name uses, so if it's safe for one it's safe for both.

Test plan

  • make oracle — 15 tests green.
  • make rebac — 18 tests green.
  • make labels — 19 tests green.
  • make test — 61 unit tests green, 21 integration skipped w/o env vars.
  • make differential-gate (5000 examples, 4 properties) — green in ~2m25s locally.
  • make integration (pgvector+Redis, real containers) — 21 tests green.
  • CI: 2 jobs, both expected green (see workflow run).

Invariant checklist

  • make oracle green.
  • make test green.
  • Differential harness: 4 properties (safety, fidelity, pointwise, superset) all green at 5000 examples.
  • No shared code between core/oracle.py and core/rebac.py/core/labels.py.
  • Gate 1 (pre-filter) is permissive-superset asserted by the new property test.
  • Gate 2 semantics unchanged; the new label pushdown is optimization only.

Security-sensitive?

  • This PR touches core/labels.py (new), the pre-filter (Gate 1). It does not touch core/algebra.py, core/oracle.py, core/rebac.py, or the gate wiring.

The failure mode this PR could introduce is silent recall loss (under-permissive filter). The new superset property test is specifically designed to catch that class of bug, and it runs at 5000 examples in CI.

  • I have read the invariants section of the README.
  • Not fixing an undisclosed vulnerability.

Notes for reviewers

  • The asymmetric invalidation is the whole architectural point of this milestone. See the module docstring in core/labels.py and the two fixture tests test_cache_returns_stale_after_write_without_invalidate (proves the bug is real) and test_cache_invalidate_forces_recompute (proves the fix works).
  • _incoming_grant_tuples uses store._outgoing bucket iteration for InMemoryStore. Storage-backed stores will override with a real reverse-index query in W3+. Same pattern as expand() from W1 — kept minimal to avoid designing an API we don't need yet.
  • Barrier tag encoding (tag(b, side) = b*2 + side) is reused from core/algebra.py. Users on both sides of the same barrier get both tags in B(u) — verified by fixture.
  • Deferred to W4 per the milestone split: recall-vs-selectivity chart, head-to-head strategy benchmarks on 1M+ synthetic corpus. This PR ships the machinery; W4 ships the plotted numbers per its own issue (W4.3 — Four benchmark tables (overhead / recall / scaling / revocation) #17).

…rieval

Closes #7, #8, #9. Delivers the W2 acceptance gate: LabelFilter(u) is a
permissive superset of Authorized(u), verified across 5000 generated
graphs by a new differential property test.

core/labels.py
  Stable 31-bit label encoding via zlib+type-prefix. materialize_doc_labels
  is a doc-side forward walk over incident grant tuples. labels_for is a
  principal-side reverse walk that produces (L, B) — self-label + every
  container the principal is transitively a member of, plus opposite-side
  barrier tags. Two cache implementations behind a LabelCache Protocol:
  InMemoryCache for property tests, RedisCache for production. Asymmetric
  invalidation is DOCUMENTED HERE: grants must call invalidate() before
  returning (missed = silent recall loss); revocations can rely on TTL
  (Gate 2 catches).

evals/differential.py
  test_label_filter_is_permissive_superset — the W2 acceptance property.
  For every principal in every generated graph, docs the oracle allows
  MUST also pass the label filter (acl_labels && L, minus barrier_tags &
  B). Reverse inclusion is allowed and expected; under-permission is the
  failure mode.

  Green across 5000 examples locally in ~2m25s (all 4 differential
  properties combined).

sql/002_documents.sql
  documents table with pgvector embedding(768), int[] acl_labels and
  barrier_tags, LIST partitioning by org_id. Partitioning is defense-in-
  depth: a label-materialization bug cannot cross partition boundaries
  because the query never touches another partition's heap.

retrieval/index.py
  ensure_org_partition (idempotent CREATE ... IF NOT EXISTS + per-
  partition GIN + HNSW), upsert_document. Partition creation uses SQL-
  literal interpolation of org_id (Postgres DDL doesn't accept bind
  params for FOR VALUES IN) — gated by an alphanumeric+_ regex.

retrieval/strategies.py
  Three filtered-ANN strategies behind a Strategy enum: exact (flat scan
  on filtered set), iterative (pgvector 0.8 iterative_scan), partitioned
  (iterative scoped to one org partition). All three return the same
  Candidate shape so W4 benchmarks can compare them head-to-head on
  identical corpora.

tests/test_labels.py
  19 handwritten fixtures: label determinism, namespace disjointness,
  31-bit fit for int4, doc materialization for direct/group/owner grants,
  L transitively contains group+org memberships, B contains opposite
  barrier side, cache stale-vs-invalidated semantics.

tests/test_retrieval_pgvector.py
  10 integration tests. All 3 strategies parametrized: seed 5 docs,
  retrieve top-3, exclude barrier-blocked docs, empty on no overlap,
  partitioned scopes to org. Skipped without WARDEN_TEST_DB_URL.

tests/test_labels_redis.py
  4 tests against real Redis: set/get roundtrip, miss returns None,
  invalidate removes entry, TTL applied (catches the "forgot ex="
  bug that makes revocation lag unbounded).

docker-compose.yml
  Switched postgres:16-alpine → pgvector/pgvector:pg16. Added redis:7-
  alpine service.

.github/workflows/ci.yml
  Renamed postgres-integration → integration; now spins up both pgvector
  and redis service containers and runs all three integration test files.
  Fast job also includes tests/test_labels.py.

Test state: 61 unit tests + 4 differential properties (5000 examples,
in CI) + 14 integration tests (Postgres 3 + pgvector 10 + Redis 4).
All green.

Deferred to W4 per the milestone split: recall-vs-selectivity chart,
head-to-head strategy benchmarks, full concurrent grant/revoke stress
test. This PR ships the machinery; W4 ships the plotted numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@geminimir
geminimir merged commit 47083c4 into main Jul 16, 2026
3 checks passed
@geminimir
geminimir deleted the w2/retrieval-index branch July 16, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

W2.1 — Label materialization + Redis cache with asymmetric invalidation

1 participant