Enforced inside the plan
The ACL is a SQL predicate every retrieval leg shares, so a document you may not see is never fetched, never ranked, and never reaches a reranker.
Permission-aware retrieval
Every point above is a chunk of somebody’s corpus, clustered by the team that owns it. Ask a question and the index returns the nearest ones — then your ACL deletes most of what came back. Nothing in the stack reports the loss. The answer just arrives thinner.
The mechanism
An approximate index walks its neighbours in distance order, discards the ones your filter rejects as it meets them, and stops when its budget is spent. When a team's documents sit together in the embedding space — as they always do — that budget goes almost entirely on rows the caller may not read. Below: one query, its search radius, and who is inside it.
19 of 20 permitted rows returned · 1 withheld Measured recall 0.980.
The easy case. Permissions assigned at random scatter every group evenly through the vector space, so the neighbourhood around any query already contains rows this caller can read. Almost nothing is lost — and almost no real corpus looks like this.
The real case. A group’s visible slice is a region of the embedding space, not a uniform sample of it. The index spends its whole budget on the nearest neighbours — which belong overwhelmingly to other groups — and stops. The rows this caller is entitled to sit just outside the ring, and no error is raised. A quarter of queries come back empty.
The fix. Iterative scan keeps widening the radius until it has actually filled the limit with rows that pass the check, instead of stopping at the first budget. Watch the ring grow. It does not fully close the gap — 0.928 against 0.980 — which is why the library decides between an exact and an approximate scan per query rather than tuning a parameter and hoping.
Measured on 200,000 documents from BEIR nq at 1536 dimensions, scored against an exact brute-force oracle over the eligible rows. Drop visibility to 1% and clustered permissions return nothing at all for 75% of queries — not a degraded answer, an empty one.
Don't take our number
A benchmark on someone else's data proves nothing about yours — the loss depends entirely on the shape of your permissions. So the measurement ships as a command. It samples your real ACL values, replays query vectors against your own index, and never sends a byte anywhere.
$ context-engine check-acl-exposure --database-url $DATABASE_URL --samples 200
Corpus: 214,880 chunks with embeddings top_k=10
principals visible plan plain engine empty p50
-----------------------------------------------------------------------------------
group:legal 3.4% hnsw 0.312 0.941 31.0% 18.7ms
group:finance 8.1% hnsw 0.508 0.933 9.5% 16.2ms
group:hr 11.9% bitmap+sort 0.964 0.971 0.0% 22.4ms
plain = a bare scoped query, which is what most stacks issue
engine = this library's shipped retrieval path on the same rows
Both are scored against an exact scan over the eligible rows.
VERDICT: exposed — the worst scope (group:legal) returns only 31.2% of what an
exact search would, and 31% of queries came back empty.
Callers in that scope are silently getting fewer results than exist.
No error is raised and nothing is logged, so this does not show up in
monitoring.Sample output — your numbers depend on the shape of your permissions
What it is
Full-text, trigram, vector and graph legs fuse through Reciprocal Rank Fusion — and all four compile through one scope predicate. There is no permissive default and no way to call retrieval without saying who is asking.
The ACL is a SQL predicate every retrieval leg shares, so a document you may not see is never fetched, never ranked, and never reaches a reranker.
A pure transform — no model, no network. Mask at ingest and the secret never enters the index; mask at output and it never leaves. ACL decides who, redaction decides what.
BYO database, embedding provider and LLM. The package never calls a hosted service you didn't configure — no control plane, no egress, no vendor holding your corpus.
Two clients, one Postgres schema — a corpus ingested by either is searchable by the other. Serve it on FastAPI, Flask, Django, Hono, Express, Fastify, or over MCP.
Requirements are a database, not a stack: PostgreSQL 14+ with vector (pgvector 0.8+) for the semantic leg, pg_trgm for fuzzy and partial matching, and unaccent so café and cafe match. All three are stock PostgreSQL and the engine creates them itself on the first migration — you install nothing by hand.
Start here
Install it, point it at the database you already have, and read the verdict it prints. If the loss isn't there, you've spent four minutes.