03

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

System map · Day 03

Whole-system design

5 stable layers. Today's work is expanded and linked; the rest stays in context.

Entry and authorization control

Covered — Request entry · Approval gate

Tenant and policy authority

Source-backed today

Supplies tenant scope from runtime context rather than prompt text.

Compute and execution

Covered — Typed tool broker

Agent runtime compute

Source-backed today

Consumes only tenant-scoped candidates and immutable citation identifiers.

Policy retrieval compute

Source-backed today

Filters before ranking and preserves stale or conflicting versions for evaluation.

Enterprise resource boundary

Simulated enterprise system

Source-backed today

Makes colliding Acme and Globex identifiers unable to cross the adapter boundary.

Storage and state

Covered — Run and effect state

Evaluation evidence store

Source-backed today

Stores ten collision attacks with paired same-tenant positive controls.

Evidence and release control

Ahead — Evaluation harness compute · Trace and diagnosis plane · Release gate

Traversed today

bound request · Request entryAgent runtime computescoped policy search · Tenant and policy authorityPolicy retrieval computeauthorized effect · Typed tool brokerSimulated enterprise systempost-effect state · Simulated enterprise systemAgent runtime compute

Treat tenant scope as authorization

Week 2 left R2-agent-v1, 30 cases, and five brokered tools. Those tools are bounded, but a search system can still retrieve a persuasive Globex policy for an Acme request if tenant filtering happens after global ranking. This week you make tenant scope an authorization predicate applied before candidates exist, then prove ten collision attacks return zero foreign rows, citations, or effects.

Retrieval-augmented generation (RAG) supplies selected source passages to a model before it answers. Retrieval improves factual context but does not establish access control. Search ranking answers “which allowed document is relevant?”; authorization answers “which documents may become candidates?” Authorization must run first.

Keep retrieval opaque until ownership is clear. Trusted runtime context supplies tenant scope; policy retrieval may rank only that tenant’s candidates; the agent receives passages, never permission to widen the namespace.

Trusted tenant enters through tagged contracts.py. Policy versions live on passages in the offline reference; the richer request-level as-of binding is an exercise target:

@dataclass(frozen=True)
class RuntimeContext:
    tenant_id: str
    actor_id: str

The server creates context; request prose cannot override it. A wrong or absent tenant is a denial, not a model clarification.

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.