AID-I-004.002: Persistent Memory Partitioning (Trust & Tenant Isolation)

Version 1.20261015 · Actionable sub-technique

Defines structural isolation for long-term memory (Vector DB/RAG). Uses namespaces/collections partitioned by tenant and trust tier, and enforces retrieval authorization via a centralized policy decision (never by agent self-assertion).

Pillars: data

Lifecycle: building, operation

Scope boundary

Owns structural partitioning and retrieval authorization for persistent memory, vector stores, and RAG namespaces by tenant and trust tier. It does not define whether a candidate write is eligible for a trust tier or govern volatile active context.

AID-I-004.001: AID-I-004.002 isolates persistent long-term memory namespaces; AID-I-004.001 isolates volatile session and tenant working context. Persistent partitioning and active-context hygiene have different stores, lifetimes, evidence populations, and failure modes.

AID-M-002.004: AID-I-004.002 enforces persistent namespace and retrieval isolation; AID-M-002.004 defines the provenance, write-eligibility, trust-tier, evidence, and namespace metadata contract. The Model control publishes which state and namespace a write should receive; this control makes that partition and retrieval boundary effective at runtime.

Implementation guidance

AID-I-004.002-G001

Partition long-term memory by tenant + trust tier; retrieval must consult a central entitlement/policy service and be fully audited.
Security boundary

The retrieval gate must obtain scope and lease state from trusted authorities. A process wall clock is not authorization evidence. Bind the authorization to a signed lease ID/sequence, use authority-provided time, and revalidate immediately before and after every namespace query and again before return. Discard every accumulated hit if the lease expires, authority becomes unavailable, a query times out, or any partition/result binding differs.

Use a bounded lease-aware retrieval gate
# File: memory/retrieval_gate.py
from __future__ import annotations

import hashlib
import json
import math
from dataclasses import dataclass
from typing import Literal, Protocol


@dataclass(frozen=True)
class AuthenticatedPrincipal:
    principal_id: str
    tenant_id: str
    authentication_event_id: str


@dataclass(frozen=True)
class VerifiedRetrievalScope:
    decision_id: str
    principal_id: str
    tenant_id: str
    policy_version: str
    policy_sha256: str
    query_sha256: str
    collection: str
    namespaces: tuple[str, ...]
    result_limit: int
    lease_id: str
    lease_sequence: int
    expires_at_authority_unix: float
    per_query_timeout_seconds: float
    total_timeout_seconds: float
    receipt_sha256: str


@dataclass(frozen=True)
class VerifiedLeaseCheck:
    state: Literal["ACTIVE", "EXPIRED", "REVOKED"]
    lease_id: str
    lease_sequence: int
    authority_time_unix: float
    expires_at_authority_unix: float
    receipt_sha256: str


@dataclass(frozen=True)
class SearchHit:
    hit_id: str
    tenant_id: str
    namespace: str
    score: float
    payload: dict


@dataclass(frozen=True)
class RetrievalResult:
    control_status: Literal["PASS", "FAIL", "INSUFFICIENT_DATA", "ERROR"]
    finding_status: Literal["NO_FINDING", "FINDING", "UNKNOWN"]
    hits: tuple[SearchHit, ...]
    decision_id: str
    final_lease_receipt_sha256: str | None
    audit_receipt_id: str | None
    detail: str


class EntitlementAuthorizer(Protocol):
    def authorize_retrieval(
        self, *, principal: AuthenticatedPrincipal, query_sha256: str,
    ) -> VerifiedRetrievalScope: ...


class RetrievalLeaseAuthority(Protocol):
    def revalidate(
        self, *, scope: VerifiedRetrievalScope, stage: str,
    ) -> VerifiedLeaseCheck: ...


class PartitionedVectorStore(Protocol):
    def search(
        self,
        *,
        collection: str,
        namespace: str,
        tenant_filter: str,
        query_vector: tuple[float, ...],
        limit: int,
        timeout_seconds: float,
    ) -> list[SearchHit]: ...


class AppendOnlyRetrievalAudit(Protocol):
    def append(self, event: dict) -> str: ...
    def read(self, receipt_id: str) -> dict: ...


class RetrievalHalt(Exception):
    def __init__(self, control_status: str, finding_status: str, detail: str):
        super().__init__(detail)
        self.control_status = control_status
        self.finding_status = finding_status
        self.detail = detail


def canonical_digest(value) -> str:
    raw = json.dumps(
        value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
        allow_nan=False,
    ).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def secure_vector_search(
    *,
    vector_store: PartitionedVectorStore,
    authorizer: EntitlementAuthorizer,
    lease_authority: RetrievalLeaseAuthority,
    audit: AppendOnlyRetrievalAudit,
    principal: AuthenticatedPrincipal,
    query_vector: list[float],
) -> RetrievalResult:
    if not all((
        principal.principal_id, principal.tenant_id,
        principal.authentication_event_id,
    )):
        raise PermissionError("authenticated principal is incomplete")
    if (
        not isinstance(query_vector, list)
        or not query_vector
        or any(
            isinstance(value, bool)
            or not isinstance(value, (int, float))
            or not math.isfinite(value)
            for value in query_vector
        )
    ):
        raise ValueError("query vector is empty or non-finite")

    vector = tuple(float(value) for value in query_vector)
    query_sha256 = canonical_digest(list(vector))
    scope = authorizer.authorize_retrieval(
        principal=principal, query_sha256=query_sha256,
    )
    if (
        scope.principal_id != principal.principal_id
        or scope.tenant_id != principal.tenant_id
        or scope.query_sha256 != query_sha256
        or not all((
            scope.decision_id, scope.policy_version, scope.collection,
            scope.lease_id,
        ))
        or len(scope.policy_sha256) != 64
        or len(scope.receipt_sha256) != 64
        or not scope.namespaces
        or tuple(sorted(set(scope.namespaces))) != scope.namespaces
        or any(not namespace for namespace in scope.namespaces)
        or isinstance(scope.result_limit, bool)
        or not isinstance(scope.result_limit, int)
        or scope.result_limit < 1
        or isinstance(scope.lease_sequence, bool)
        or scope.lease_sequence < 1
        or any(
            isinstance(value, bool)
            or not isinstance(value, (int, float))
            or not math.isfinite(value)
            or value <= 0
            for value in (
                scope.expires_at_authority_unix,
                scope.per_query_timeout_seconds,
                scope.total_timeout_seconds,
            )
        )
    ):
        raise PermissionError("verified retrieval scope binding is invalid")

    last_check: VerifiedLeaseCheck | None = None

    def nonpass(
        status: str, finding: str, detail: str,
    ) -> RetrievalResult:
        return RetrievalResult(
            control_status=status,
            finding_status=finding,
            hits=(),
            decision_id=scope.decision_id,
            final_lease_receipt_sha256=(
                None if last_check is None else last_check.receipt_sha256
            ),
            audit_receipt_id=None,
            detail=detail,
        )

    def revalidate(stage: str) -> VerifiedLeaseCheck:
        nonlocal last_check
        try:
            check = lease_authority.revalidate(scope=scope, stage=stage)
        except Exception as exc:
            raise RetrievalHalt(
                "ERROR", "UNKNOWN", f"lease authority unavailable at {stage}",
            ) from exc
        if (
            check.lease_id != scope.lease_id
            or check.lease_sequence != scope.lease_sequence
            or check.expires_at_authority_unix != scope.expires_at_authority_unix
            or len(check.receipt_sha256) != 64
            or not math.isfinite(check.authority_time_unix)
            or not math.isfinite(check.expires_at_authority_unix)
        ):
            raise RetrievalHalt(
                "FAIL", "FINDING", f"lease binding differs at {stage}",
            )
        last_check = check
        if (
            check.state != "ACTIVE"
            or check.authority_time_unix >= check.expires_at_authority_unix
        ):
            raise RetrievalHalt(
                "FAIL", "FINDING", f"lease is not active at {stage}",
            )
        return check

    try:
        first_check = revalidate("after_authorization")
        total_deadline = min(
            first_check.expires_at_authority_unix,
            first_check.authority_time_unix + scope.total_timeout_seconds,
        )
        hits: list[SearchHit] = []
        seen_ids: set[str] = set()
        query_receipts: list[str] = []
        for namespace in scope.namespaces:
            remaining = scope.result_limit - len(hits)
            if remaining == 0:
                break
            before = revalidate(f"before_query:{namespace}")
            remaining_window = min(
                before.expires_at_authority_unix,
                total_deadline,
            ) - before.authority_time_unix
            query_timeout = min(
                scope.per_query_timeout_seconds, remaining_window,
            )
            if not math.isfinite(query_timeout) or query_timeout <= 0:
                raise RetrievalHalt(
                    "FAIL", "FINDING",
                    f"lease or total timeout exhausted before {namespace}",
                )
            try:
                page = vector_store.search(
                    collection=scope.collection,
                    namespace=namespace,
                    tenant_filter=scope.tenant_id,
                    query_vector=vector,
                    limit=remaining,
                    timeout_seconds=query_timeout,
                )
            except TimeoutError as exc:
                raise RetrievalHalt(
                    "FAIL", "FINDING", f"namespace query timed out: {namespace}",
                ) from exc
            except Exception as exc:
                raise RetrievalHalt(
                    "ERROR", "UNKNOWN", f"namespace query failed: {namespace}",
                ) from exc
            after = revalidate(f"after_query:{namespace}")
            if after.authority_time_unix >= total_deadline:
                raise RetrievalHalt(
                    "FAIL", "FINDING",
                    f"total timeout exhausted after {namespace}",
                )
            query_receipts.extend((before.receipt_sha256, after.receipt_sha256))
            if not isinstance(page, list) or len(page) > remaining:
                raise RetrievalHalt(
                    "FAIL", "FINDING",
                    "vector store exceeded the global result budget",
                )
            for hit in page:
                if (
                    not isinstance(hit, SearchHit)
                    or not hit.hit_id
                    or hit.hit_id in seen_ids
                    or hit.tenant_id != scope.tenant_id
                    or hit.namespace != namespace
                    or not math.isfinite(hit.score)
                ):
                    raise RetrievalHalt(
                        "FAIL", "FINDING",
                        "vector-store result escaped the verified partition",
                    )
                seen_ids.add(hit.hit_id)
                hits.append(hit)

        before_audit = revalidate("before_success_audit")
        event = {
            "schema_version": "aidefend.partitioned-retrieval-candidate.v2",
            "decision_id": scope.decision_id,
            "authorization_receipt_sha256": scope.receipt_sha256,
            "authentication_event_id": principal.authentication_event_id,
            "principal_id": principal.principal_id,
            "tenant_id": scope.tenant_id,
            "policy_version": scope.policy_version,
            "policy_sha256": scope.policy_sha256,
            "query_sha256": query_sha256,
            "collection": scope.collection,
            "namespaces": list(scope.namespaces),
            "global_result_limit": scope.result_limit,
            "lease_id": scope.lease_id,
            "lease_sequence": scope.lease_sequence,
            "lease_expires_at_authority_unix": scope.expires_at_authority_unix,
            "query_lease_receipt_sha256s": query_receipts,
            "pre_audit_lease_receipt_sha256": before_audit.receipt_sha256,
            "returned_hit_ids": [hit.hit_id for hit in hits],
            "returned_hit_count": len(hits),
            "outcome": "AUTHORIZED_RESULT_CANDIDATE",
        }
        try:
            audit_receipt_id = audit.append(event)
            if audit.read(audit_receipt_id) != event:
                raise RuntimeError("retrieval audit readback differs")
        except Exception as exc:
            raise RetrievalHalt(
                "ERROR", "UNKNOWN", "retrieval audit unavailable or differs",
            ) from exc
        final_check = revalidate("before_return")
        if final_check.authority_time_unix >= total_deadline:
            raise RetrievalHalt(
                "FAIL", "FINDING", "total timeout exhausted before return",
            )
        return RetrievalResult(
            control_status="PASS",
            finding_status="NO_FINDING",
            hits=tuple(hits),
            decision_id=scope.decision_id,
            final_lease_receipt_sha256=final_check.receipt_sha256,
            audit_receipt_id=audit_receipt_id,
            detail="authorized partitions returned within the active lease",
        )
    except RetrievalHalt as halt:
        return nonpass(halt.control_status, halt.finding_status, halt.detail)
Executable regression fixtures
# File: tests/test_retrieval_gate.py
from types import SimpleNamespace

from memory.retrieval_gate import (
    AuthenticatedPrincipal,
    SearchHit,
    VerifiedLeaseCheck,
    VerifiedRetrievalScope,
    canonical_digest,
    secure_vector_search,
)


class LeaseAuthority:
    def __init__(self, *, expire_at=None, outage_at=None):
        self.calls = []
        self.expire_at = expire_at
        self.outage_at = outage_at

    def revalidate(self, *, scope, stage):
        self.calls.append(stage)
        if stage == self.outage_at:
            raise ConnectionError("authority outage")
        now = 100.0 + len(self.calls)
        state = "EXPIRED" if stage == self.expire_at else "ACTIVE"
        return VerifiedLeaseCheck(
            state, scope.lease_id, scope.lease_sequence, now,
            scope.expires_at_authority_unix, "l" * 64,
        )


class Store:
    def __init__(self):
        self.calls = []

    def search(self, **values):
        self.calls.append(values)
        namespace = values["namespace"]
        return [SearchHit(
            f"hit-{namespace}", "tenant-a", namespace, 0.9, {"safe": True},
        )]


class Audit:
    def __init__(self):
        self.values = {}

    def append(self, event):
        receipt_id = canonical_digest(event)
        self.values[receipt_id] = event
        return receipt_id

    def read(self, receipt_id):
        return self.values[receipt_id]


principal = AuthenticatedPrincipal("principal-a", "tenant-a", "auth-1")
vector = [0.25, 0.75]
scope = VerifiedRetrievalScope(
    "decision-1", "principal-a", "tenant-a", "policy-7", "p" * 64,
    canonical_digest(vector), "memory", ("ns-a", "ns-b"), 2,
    "lease-1", 11, 200.0, 2.0, 10.0, "r" * 64,
)


def run(authority):
    store = Store()
    result = secure_vector_search(
        vector_store=store,
        authorizer=SimpleNamespace(authorize_retrieval=lambda **_: scope),
        lease_authority=authority,
        audit=Audit(),
        principal=principal,
        query_vector=vector,
    )
    return result, store


def test_expiry_after_query_discards_accumulated_hits():
    result, store = run(LeaseAuthority(expire_at="after_query:ns-a"))
    assert store.calls
    assert result.control_status == "FAIL"
    assert result.hits == ()


def test_authority_outage_before_second_namespace_discards_first_page():
    result, _ = run(LeaseAuthority(outage_at="before_query:ns-b"))
    assert (result.control_status, result.finding_status) == ("ERROR", "UNKNOWN")
    assert result.hits == ()


def test_success_revalidates_every_boundary_and_bounds_timeouts():
    authority = LeaseAuthority()
    result, store = run(authority)
    assert (result.control_status, len(result.hits)) == ("PASS", 2)
    assert authority.calls == [
        "after_authorization",
        "before_query:ns-a", "after_query:ns-a",
        "before_query:ns-b", "after_query:ns-b",
        "before_success_audit", "before_return",
    ]
    assert all(0 < call["timeout_seconds"] <= 2.0 for call in store.calls)

Action: The vector adapter must enforce the passed timeout server-side and the authority adapter must verify its own signatures, signer authorization, revocation state, and monotonic lease sequence. Treat expiry, revocation, timeout, authority/audit outage, and stale or mismatched receipts as non-PASS; never return partial or previously accumulated hits on those paths.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-I-004.002: Persistent Memory Partitioning (Trust & Tenant Isolation) | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version