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 search 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
A search that does not know who is asking returns the nearest rows in the whole corpus, and the permission filter runs on what came back. When a team's documents sit together in the embedding space, that limit goes almost entirely on rows the caller may not read. Fetching more helps less than it should, for the same reason. Below: one query, how far the search reached, and who is inside it.
2 of 20 permitted rows returned · 18 withheld Measured recall 0.084.
Search first, filter afterwards. The search does not know who is asking, so it spends its limit on the nearest rows in the whole corpus. Those belong overwhelmingly to other groups, and the filter removes them. Measured: recall 0.084, and 63.5% of searches came back with nothing at all. No error is raised.
The usual workaround. Fetch ten times more, then filter. Watch the ring grow. It helps, by an amount that depends on where the caller’s documents sit: with team-shaped permissions the measured recall was 0.419, with 13.0% of searches still empty. The same fetch under randomly assigned permissions recovered 0.730, which is why a benchmark with random permissions makes this look safer than it is.
The fix. The permission is part of the SQL of every retrieval leg, so only rows this caller may read are ever ranked. Measured: the same top ten as a corpus holding only the caller’s documents, on every one of 200 searches. That is agreement with such a corpus, not relevance, and the vector leg is exact here because the caller sees fewer than 50,000 chunks; above that it is an approximate index scan.
Measured on 100,000 documents from BEIR HotpotQA (100,646 chunks) with hybrid search at its shipped defaults, 200 searches, and synthetic team-shaped permissions under which the caller sees 10% of the corpus. Recall is agreement with a corpus that holds only the caller’s documents, not relevance, and the vector leg is exact here because the caller sees fewer than 50,000 chunks. Drop visibility to 1% and filtering after the search returns nothing at all for 99.0% of searches: not a degraded answer, an empty one. The plot illustrates a single query on a toy corpus. The recall readout is the measured figure, and the result slots are that same figure drawn on 20 slots. Every table, the conditions and what was not measured are on the benchmarks page.
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 200The report lists your corpus size and, for each ACL value that occurs in your data, its share of visible rows, the query plan Postgres chose, the recall at the top k (10 by default) of a bare scoped query (plain) and of this library's shipped path on the same rows (engine), the share of queries that came back empty, and the median latency. Both recall columns are scored against an exact scan over the eligible rows. It ends with a verdict of ok, degraded or exposed for the worst scope, and exits non-zero only when the shipped path itself under-returns, so it can gate a deployment.
No sample figures shown. 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.