Your RAG Filter Runs Too Late. Build a Tenant-Safe Retriever in TypeScript. A developer published a TypeScript teaching build demonstrating that post-retrieval tenant filtering in RAG pipelines is both a retrieval bug and a data-boundary bug, since globally top-ranked documents from other tenants can crowd out authorized results before any permission check runs. The fixture corpus shows the authorized top two documents (a1, a2) are never returned when the global top-k is filtered after ranking, and the code argues the correct order is verified principal, eligible documents, ranking, reranker, then authorized citations. The repository runs on Node.js 22.18+ using native TypeScript stripping with no API key or model call required. Your RAG system finds the two highest-scoring documents. Then it checks whether the user can read them. Both belong to another tenant. You drop them and tell the user there is no answer. But there were three useful documents the user could read. They never made the candidate list. That is a retrieval bug. If those first two documents reached an external reranker before you dropped them, it is also a data-boundary bug. Let's build the smallest version that makes both mistakes visible. A tenant filter is not a prompt instruction. It is a predicate on the documents the application is allowed to retrieve for this authenticated user. Tenant membership is only one part of it. Groups, document ACLs and permission changes matter too. The ordering we want is: php verified principal - eligible documents - ranking and top-k - reranker and model - authorized citations Not every database implements filtering in the same way. Microsoft's Azure AI Search documentation https://learn.microsoft.com/en-us/azure/search/vector-search-filters distinguishes prefiltering during graph traversal from postfiltering after search. It also documents a separate strict postfilter mode that filters the unfiltered global top-k, which can return zero results even when eligible matches exist. Selective prefilters can increase traversal cost and latency. The array example below represents the global-top-k-then-filter mistake. It is not an implementation of Azure's sharded postFilter algorithm. The query is refund policy . Our authenticated fixture user belongs to tenant acme and group support . | Document | Tenant | Allowed group | Synthetic score | |---|---|---|---| | b1 | beta | support | 0.99 | | b2 | beta | support | 0.98 | | a-hr | acme | hr | 0.95 | | a1 | acme | support | 0.91 | | a2 | acme | support | 0.89 | | a3 | acme | support | 0.80 | These scores are invented inputs. There is no embedding model hiding behind them. The exact authorized top two are a1 and a2 . Our fixture recall is how many of those two the pipeline returns, divided by two. Globally, b1 and b2 rank first. Filter that list and you get nothing. Increasing k might recover eligible hits in this small corpus, but it does not establish an authorization boundary. The public GitHub repository https://github.com/bobbyhalljr/tenant-safe-rag contains the source, fixture data, README, diagrams and expected output. You need Node.js 22.18 or newer. Node documents native TypeScript stripping https://nodejs.org/api/typescript.html ; it executes this erasable syntax without a separate compiler. It does not type-check the file. git clone https://github.com/bobbyhalljr/tenant-safe-rag.git cd tenant-safe-rag node --experimental-strip-types rag.ts No install, API key or model call is required. Here is the complete teaching build: python import assert from 'node:assert/strict'; type Principal = { tenantId: string; groups: readonly string }; type Request = { query: string }; type Doc = { id: string; tenantId: string; groups: readonly string ; text: string }; type Hit = Doc & { score: number }; // Synthetic ranking scores for ONE query. This is not an embedding model. const query = 'refund policy'; const docs: Doc = { id: 'b1', tenantId: 'beta', groups: 'support' , text: 'Beta private refund policy' }, { id: 'b2', tenantId: 'beta', groups: 'support' , text: 'Beta private refund exception' }, { id: 'a-hr', tenantId: 'acme', groups: 'hr' , text: 'Acme private employee refund' }, { id: 'a1', tenantId: 'acme', groups: 'support' , text: 'Acme refund policy' }, { id: 'a2', tenantId: 'acme', groups: 'support' , text: 'Acme refund exception' }, { id: 'a3', tenantId: 'acme', groups: 'support' , text: 'Acme refund FAQ' } ; const scores: Record