Filter Tenant Policy Before Ranking
Week 3 of 10 · Make cross-tenant retrieval and effects impossible by construction • Public source target: https://github.com/ZGTR/enterprise-access-agent-evals/tree/v1.0.2
Whole-system design
5 stable layers. Today's work is expanded and linked; the rest stays in context.
Entry and authorization control
Tenant and policy authority
Supplies tenant scope from runtime context rather than prompt text.
Compute and execution
Agent runtime compute
Consumes only tenant-scoped candidates and immutable citation identifiers.
Policy retrieval compute
Filters before ranking and preserves stale or conflicting versions for evaluation.
Enterprise resource boundary
Simulated enterprise system
Makes colliding Acme and Globex identifiers unable to cross the adapter boundary.
Storage and state
Evaluation evidence store
Stores ten collision attacks with paired same-tenant positive controls.
Evidence and release control
Traversed today
Build colliding Acme and Globex corpora
Friendly fixtures rarely reveal isolation bugs, so both tenants need the same names, resource aliases, document IDs, and policy phrases. Give Acme and Globex an employee named Sarah, a salesforce-standard permission, and a POL-17 document whose rule differs. The tenant namespace, not identifier uniqueness, carries isolation.
Tagged retrieval.py constrains candidates before scoring:
scoped = (policy for policy in policies if policy.tenant_id == tenant_id)
terms = {term.casefold() for term in query.split()}
ranked = sorted(
scoped,
key=lambda policy: (
-sum(term in policy.text.casefold() for term in terms),
not policy.active,
policy.citation_id,
),
)
return tuple(ranked[:limit])
Each returned passage carries immutable tenant_id, document_id, version, and chunk ID. Stale or conflicting same-tenant documents stay available as controlled fixtures so later graders can distinguish retrieval from reasoning. Foreign documents never enter the ranker, prompt, logs, or citations.
The black box expands into an ordered pipeline. Reversing scope and rank is not an optimization change; it changes which documents become observable intermediate state.
Carry scope through agent and adapter boundaries
Filtering policy search alone is insufficient if employee lookup or state adapters accept a model-supplied tenant. Every boundary derives tenant from the same trusted context and verifies returned objects before use.
Tagged agent.py provides a deterministic normalization seam. A provider-backed runtime must consume opaque citation and employee references without synthesizing identifiers or rewriting tenant ownership.
class DeterministicProvider:
def normalize(self, request: RestoreAccessRequest) -> RestoreAccessRequest:
return request
The simulated authority in state.py keys all state by tenant and rejects a mismatched employee/resource pair with non-enumerating absence. “Not found” must not reveal whether the same ID exists in another tenant.
The same construction appears in tagged tools.py, not only in policy search:
def find_employee(self, query: str) -> tuple[Employee, ...]:
folded = query.casefold()
return tuple(
employee
for employee in self.state.employees
if employee.tenant_id == self.context.tenant_id and folded in employee.name.casefold()
)
def inspect_access(self, employee_id: str, resource: str) -> AccessSnapshot | None:
return self.state.access.get((self.context.tenant_id, employee_id, resource))
Python evaluates the tenant predicate while constructing employee candidates, while access lookup addresses a tenant-qualified key directly. CPU scans the local employee tuple; memory holds synthetic records. In production, equivalent enforcement belongs in the database/index query and service authorization layer so foreign rows never cross the network boundary.
Attack collisions and measure unaffected controls
Ten attacks cover prompt-selected tenant, foreign employee ID, foreign resource ID, colliding document ID, globally ranked passage, citation substitution, stale foreign approval, cross-tenant retry, error-message enumeration, and poisoned policy text asking the agent to switch scope. Each attack expects zero foreign identifiers and zero effects.
The dataset grows to 40 cases. Each attack is paired with a same-tenant positive control so a broken search service cannot “pass” isolation by returning nothing to everyone. Record actor, resource, scope, precondition, expected/observed result, environment, timestamp, and immutable run ID.
Inject one bug that filters after ranking. The attack should fail because a foreign passage reached intermediate evidence even if final output hides it. Repair by moving the predicate into candidate construction, rebuild the index namespace, rerun attacks and controls, then clean up only the disposable corpus version.
The tagged regression test makes absence and positive access observable together:
def test_foreign_tenant_rows_are_impossible_to_return() -> None:
broker = ToolBroker(SimulationState.synthetic(), RuntimeContext("acme", "admin-1"))
assert {employee.employee_id for employee in broker.find_employee("Sarah")} == {"acme-sarah"}
assert all(policy.tenant_id == "acme" for policy in broker.search_policy("access"))
assert broker.inspect_access("globex-sarah", "salesforce") is None
The scored checkpoint supplies one collision trace; the unknown is allow or deny. Correct evidence contains no foreign IDs/effects and a passing same-tenant control. Misconception feedback contrasts output scrubbing with authorization-before-ranking. Decline any answer that invents tenant from prompt text.
Spend 1.5 hours on the Hugging Face Agentic RAG unit and RAGAS concepts, 4–5 hours on corpora, adapters, attacks, and repair, and 1 hour explaining why namespace constraints precede relevance. The handoff is R3-tenant-proof, D40-cases, and policy corpus v1. Week 4 adds traces capable of locating the earliest causal failure.