How to Isolate Contract Evidence by Tenant: Retrieval Checks That Survive Review
Short answer: make tenant scope a signed, server-derived capability that every retrieval stage consumes, then prove isolation with adversarial tests before enabling semantic search. A metadata filter added at the last ve
Short answer: make tenant scope a signed, server-derived capability that every retrieval stage consumes, then prove isolation with adversarial tests before enabling semantic search. A metadata filter added at the last vector query is a useful defense, but it is not an architecture.
A legal contract review workspace has an uncomfortable property: two customers can upload documents with identical party names, clause text, and filenames. Similarity search is doing exactly what it was designed to do when it returns the other customer's indemnity clause. The failure is still a data breach.
The editorial angle here is operational verification. I want an on-call engineer to be able to answer, from a trace, which tenant scope was authorized, which index was touched, and why a result was admitted. The retrieval-augmented generation (RAG) paper describes retrieval and generation as separate stages; that separation gives us a clean place to enforce and test the boundary.
What should a tenant-isolated retrieval path prove?
Start with an invariant, not a database feature: for request tenant T, every document ID entering ranking, reranking, prompt assembly, and citation rendering must belong to T. A request that cannot establish T stops before retrieval. An empty result is safer than an ambiguous result.
Keep the tenant identity out of user-controlled query text and out of a mutable client-side filter. Resolve it from the authenticated session, check the user's workspace membership, and create a narrow scope object. In Go, the scope can be deliberately boring:
type TenantScope struct {
TenantID string
Workspace string
PolicyVer string
}
func deriveScope(claims Claims, workspace string) (TenantScope, error) {
if claims.Subject == "" || workspace == "" {
return TenantScope{}, fmt.Errorf("missing authenticated scope")
}
if !claims.Workspaces[workspace] {
return TenantScope{}, fmt.Errorf("workspace membership denied")
}
return TenantScope{TenantID: claims.TenantID, Workspace: workspace, PolicyVer: "tenant-policy-v3"}, nil
}
The storage key, lexical query, vector query, and cache key should all be derived from this object. Never accept a collection name, namespace, or tenant field from the browser. This also makes capacity planning legible: the unit you measure is tenant-scoped chunks per active review, not an opaque global vector count.
How do chunking and freshness affect isolation in a contract workspace?
Chunking is a security decision because it controls the smallest retrievable object and its lineage. Store a stable document version, clause path, page span, and tenant ID beside every chunk. A chunk without a parent version cannot be reliably invalidated when counsel replaces an agreement.
For contracts, split on structural boundaries first: section, subsection, definition, and schedule. Keep a small overlap only when a cross-boundary sentence needs it. Huge windows make citations vague; tiny windows lose the exception that changes a clause. Your retrieval quality metric should therefore include citation completeness, not just top-k similarity.
Freshness needs an explicit state machine. When a new upload arrives, mark the old version superseded, index the new version, and expose only chunks whose version is active at query time. If indexing is asynchronous, the API should return a clear freshness state so reviewers know whether they are searching the prior signed copy or the pending one. Do not silently mix versions in one answer.
A useful admission predicate is small enough to review in code:
type Chunk struct { ID string; TenantID string; DocumentID string; Version int64; Active bool; Text string }
func admissible(c Chunk, scope TenantScope, activeVersion int64) bool {
return c.TenantID == scope.TenantID && c.Active && c.Version == activeVersion
}
The predicate is not a substitute for index partitioning. It is the final check before prompt assembly, where a second independent assertion can turn a missed filter into a denied result.
A staged runbook for safe retrieval
Step one is authorization and scope derivation. Emit a trace field containing a hash of the scope, never the contract text. Step two is candidate retrieval from a tenant-bounded partition or index. Step three is lexical and semantic ranking inside that candidate set. Step four is reranking with the same scope and active-version predicate. Step five is prompt construction from admitted chunks only, with document IDs and clause paths attached for citations.
Here is a generic repository interface; the implementation may be self-hosted or managed, but the method contract stays tenant-first:
type Retriever interface { Candidates(ctx context.Context, scope TenantScope, query string, limit int) ([]Chunk, error) }
func retrieve(ctx context.Context, r Retriever, scope TenantScope, query string, active map[string]int64) ([]Chunk, error) {
candidates, err := r.Candidates(ctx, scope, query, 40)
if err != nil { return nil, err }
admitted := make([]Chunk, 0, len(candidates))
for _, c := range candidates { if version, ok := active[c.DocumentID]; ok && admissible(c, scope, version) { admitted = append(admitted, c) } }
return admitted, nil
}
The important operational detail is the bounded candidate limit and a timeout on each stage. A slow reranker must not cause the API to fall back to an unscoped search. Fallback means deny, record the reason, and let the reviewer retry.
Which verification signals catch a cross-tenant leak?
Build a two-tenant fixture with deliberately identical text, then run the same query under both scopes. Assert that IDs, citations, cache entries, and token counts never cross. Repeat after deleting a document, superseding a version, and replaying an old authorization token. Property-based tests are valuable because they vary names and clause order without relying on one hand-picked phrase.
That test is cheap. Run it on every change to authorization, indexing, ranking, or caching.
Make the fixture exercise the whole request lifecycle, because isolation bugs often hide between components. Seed the same clause text under tenant A and tenant B, issue a query as A, expire the authorization context, upload a replacement version as B, and then repeat the original query with a new request ID. Compare the admitted document IDs before generation, the citation IDs after generation, the cache key material, and the freshness state shown to the reviewer. A failure at any checkpoint should fail the test, even when the final answer happens to contain no obvious secret. This sequence also gives incident responders a deterministic replay: they can inspect one trace, map every chunk to its tenant and version, and decide whether to revoke an index alias or only invalidate a cache namespace. Keep the fixture data synthetic, but keep its shape realistic enough to exercise long clauses, duplicate filenames, and concurrent uploads.
In production, alert on invariant violations rather than on a vendor-specific error. Useful counters include rejected scope derivations, candidate chunks discarded for tenant mismatch, stale-version admissions, and cache-key collisions. A trace should connect authorization, retrieval, and generation with one request ID; redact contract text and embeddings from logs.
I once expected a tenant filter test to be enough. It was not: the cache key omitted the workspace, so a perfectly filtered backend served a previous answer. The fix was to derive the key from the immutable scope and active document versions, then add a test that swaps only the tenant ID. That is the kind of boring failure that deserves a loud metric.
Trade-offs, limits, and rollback
| Choice | Helps with | Cost or limit |
|---|---|---|
| Separate index per tenant | Strong blast-radius boundary and simple deletion | More indexes to provision and monitor; unsuitable for thousands of tiny tenants without automation |
| Shared index with mandatory server filter | Lower operational overhead and easier global tuning | A missed filter is catastrophic; requires defense-in-depth checks and isolation tests |
| Hybrid lexical plus vector retrieval | Better handling of exact clause terms and semantic paraphrases | Two capacity profiles, more latency, and more freshness paths to validate |
| Synchronous reindex on upload | Clear freshness at query time | Upload latency and backpressure during large imports |
| Asynchronous reindex with version gating | Predictable write path and controlled load | Reviewers must see and understand a pending-freshness state |
The catch is that no layout removes the need for authorization tests and deletion semantics. Separate indexes are not suitable when your control plane cannot reliably create, audit, and retire them; use a shared index with strict server-side predicates only if you can demonstrate those predicates in every code path. Your mileage may vary with tenant size and contract volume, so measure p95 retrieval latency, index lag, and on-call work before committing to a topology.
For rollback, keep the previous document version addressable, disable the new index writer behind a feature flag, and reject queries whose active-version map is incomplete. Roll back the deployment and the index alias together; otherwise a healthy service can pair new authorization code with old data. After rollback, replay the two-tenant fixture and inspect traces before reopening traffic.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.