OGuardAI
GuidesCase Studies

RAG Document Pipeline

How a company safely ingests internal documents into a RAG system so the vector store and LLM never see employee or customer PII

How a company safely ingests internal documents into a RAG system so the vector store and LLM never see employee or customer PII.


Note: This scenario relies on detecting person and company names. Name, company, and location detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, with detector.mode: both or advanced); in builtin-only mode those are not detected. Set detection.required_for: [person, company] in the policy to fail closed if the sidecar is down. See Detector Capabilities.

The Situation

A 2,000-person technology company wants to build an internal knowledge assistant. The knowledge base includes HR policies, employee handbooks, customer contracts, support ticket archives, and internal memos. These documents are full of names, email addresses, phone numbers, employee IDs, customer account numbers, and salary figures.

The engineering team builds a standard RAG pipeline: chunk documents, embed them, store vectors, retrieve relevant chunks at query time, and pass them to an LLM for answer generation. The security review blocks deployment. The vector store (hosted on a managed service) would contain raw PII in the chunk text. The LLM provider would receive PII in retrieved context. Every query could leak sensitive data.


The Solution

OGuardAI is inserted at two points in the RAG pipeline: document ingestion (tokenize before embedding) and query time (transform the question, then rehydrate the answer). The vector store only ever contains tokenized text. The LLM only ever sees tokenized context.


The 4-Step RAG Flow

The vector store and the LLM only ever see tokens; raw values are restored only at the final answer, for the user. A shared corpus_id keeps token identity consistent across ingest, query, and context.

Step 1: Document Ingestion (Tokenize Before Embedding)

A document from the support archive is being ingested:

From: Erika Mustermann <erika.mustermann@example.com>
To: Support Team
Subject: Account #AC-2026-8834 - Billing dispute

Hi team,

Customer Max Mustermann (max.mustermann@example.com, phone 415-555-0192)
called about a double charge of $847.50 on invoice INV-44021.
His employee contact at our company is Moritz Mustermann (ext. 4421).
Please resolve within 24 hours per our SLA.

Erika Mustermann
Customer Success Manager

The ingestion service sends the document to OGuardAI:

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "From: Erika Mustermann <erika.mustermann@example.com>\nTo: Support Team\nSubject: Account #AC-2026-8834 - Billing dispute\n\nHi team,\n\nCustomer Max Mustermann (max.mustermann@example.com, phone 415-555-0192) called about a double charge of $847.50 on invoice INV-44021. His employee contact at our company is Moritz Mustermann (ext. 4421). Please resolve within 24 hours per our SLA.\n\nErika Mustermann\nCustomer Success Manager",
    "policy": "rag-ingest"
  }'

Detected entities:

Original ValueEntity TypeToken
Erika Mustermannperson{{person:p_001:ad4f97591c16}}
erika.mustermann@example.comemail{{email:e_001:1bcaef1a4aff}}
AC-2026-8834customer_id{{customer_id:cid_001:d00188a0c40c}}
Max Mustermannperson{{person:p_002:98e1f012a96f}}
max.mustermann@example.comemail{{email:e_002:3bd0ea511c7d}}
415-555-0192phone{{phone:ph_001:71453119be53}}
Moritz Mustermannperson{{person:p_003:e19cd15f15fb}}
$847.50custom:money{{custom:money:x_001:601595ba1e2c}}

The tokenized text stored in the vector database:

From: `{{person:p_001:ad4f97591c16}}` <`{{email:e_001:1bcaef1a4aff}}`>
To: Support Team
Subject: Account #`{{customer_id:cid_001:d00188a0c40c}}` - Billing dispute

Hi team,

Customer `{{person:p_002:98e1f012a96f}}` (`{{email:e_002:3bd0ea511c7d}}`, phone `{{phone:ph_001:71453119be53}}`)
called about a double charge of `{{custom:money:x_001:601595ba1e2c}}` on invoice INV-44021.
His employee contact at our company is `{{person:p_003:e19cd15f15fb}}` (ext. 4421).
Please resolve within 24 hours per our SLA.

`{{person:p_001:ad4f97591c16}}`
Customer Success Manager

The session state blob is stored alongside the document chunk metadata in the ingestion database (not in the vector store).

Step 2: Query Transformation

An employee asks the knowledge assistant: "What happened with the billing dispute from Max Mustermann?"

The query is transformed before retrieval:

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "What happened with the billing dispute from Max Mustermann?",
    "policy": "rag-query"
  }'

Result:

What happened with the billing dispute from `{{person:p_001:ad4f97591c16}}`?

The vector search uses the tokenized query. Because the ingested chunks also contain {{person:...}} tokens, semantic similarity still works: the embedding model captures the structural pattern around "billing dispute" and the person token.

Aligning tokens across documents and sessions with corpus_id. The simplified /v1/transform calls above assign token ids per request, so the query's "Max Mustermann" gets {{person:p_001:ad4f97591c16}} here while ingestion assigned {{person:p_002:98e1f012a96f}}. Different ids do not hurt vector similarity, but they do not let you join a query entity to a specific ingested entity. To make the same raw value tokenize to the same id across every document and query, the RAG endpoints (/v1/rag/ingest, /v1/rag/query, /v1/rag/context) accept an optional corpus_id. Pass the same corpus_id on ingest and query and "Max Mustermann" resolves to one deterministic corpus-scoped token everywhere, so the query token lines up with the ingested document token. corpus_id is corpus-scoped, not a global fingerprint; leaving it unset keeps the random per-token scheme, and /v1/rag/context rejects a mismatched corpus_id rather than restoring across identity domains.

Step 3: Context Assembly

The retrieval system finds the matching chunk (from Step 1). The tokenized chunk is passed directly to the LLM as context: it is already safe. No additional transformation is needed for the retrieved context because it was tokenized at ingestion time.

In a production pipeline that retrieves across many documents through /v1/rag/ingest and /v1/rag/context, ingest returns a per-chunk chunk_session_state (a sealed blob holding only that chunk's tokens). Store it next to the chunk in the vector database. At context time, pass the retrieved chunks plus their chunk_session_state values as the document_sessions array to /v1/rag/context. The runtime merges each admitted chunk's document tokens into the session so a document-only entity resolves in the answer, fail closed: a chunk filtered by access level is never merged, and a token id that two documents disagree on is rejected rather than restored to a wrong value.

Step 4: Answer Rehydration

The LLM generates an answer using the tokenized context:

Based on the support ticket, `{{person:p_002:98e1f012a96f}}` reported a double charge
of `{{custom:money:x_001:601595ba1e2c}}` on invoice INV-44021 for account `{{customer_id:cid_001:d00188a0c40c}}`.
The ticket was assigned to `{{person:p_003:e19cd15f15fb}}` for resolution within
24 hours per SLA. The original report was filed by `{{person:p_001:ad4f97591c16}}`
from the Customer Success team.

The answer is rehydrated for the requesting employee:

curl -X POST http://localhost:3000/v1/rehydrate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "output": "<LLM answer with tokens>",
    "session_state": "<encrypted-blob-from-ingest>",
    "output_channel": "internal_summary"
  }'

The employee sees the fully restored answer:

Based on the support ticket, Max Mustermann reported a double charge
of $847.50 on invoice INV-44021 for account AC-2026-8834. The ticket
was assigned to Moritz Mustermann for resolution within 24 hours per SLA.
The original report was filed by Erika Mustermann from the Customer Success team.

Policy Configuration

name: rag-ingest
version: "1.0"
rules:
  - entity_type: person
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: email
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: phone
    protection_level: 2
    action: tokenize
    restore_mode: masked
  - entity_type: customer_id
    protection_level: 2
    action: tokenize
    restore_mode: full
  - entity_type: ssn
    protection_level: 1
    action: redact

defaults:
  protection_level: 2
  action: tokenize
  restore_mode: full

metadata_policy:
  expose_gender: true
  expose_formality: true
  expose_language: true
  expose_role: true

channel_rules:
  internal_summary:
    person: full
    email: full
    phone: masked
    customer_id: full
  customer_email:
    person: partial
    email: none
    phone: none
    customer_id: masked

Save the policy under policies/rag-ingest/policy.yaml (and the matching rag-query policy referenced in Step 2 under policies/rag-query/), then start the server with the policy directory so both policy names resolve at request time.


What OGuardAI Made Possible

Safe vector storage. The managed vector store service contains no raw PII for the values the policy detects and tokenizes. If the vector store is breached, attackers find only semantic tokens that cannot be reversed without the encrypted session blobs stored separately in the company's infrastructure.

End-to-end tokenization. PII is tokenized once at ingestion and stays tokenized through retrieval, context assembly, and LLM processing. Real values are restored only at the final step, only for authorized users, only according to policy.

Semantic search still works. Tokenized text preserves document structure and surrounding context. The embedding model captures the meaning around tokens ("billing dispute," "double charge," "SLA"), so retrieval can still match on that surrounding context.

Redacted sensitive categories. SSN values in HR documents are replaced at ingestion with the non-restorable [REDACTED:ssn] sentinel and purged from the session. Neither the raw value nor a restorable token enters the vector store, and no later rehydrate can recover it. Adding on_redact: reject to that rule would refuse the whole document instead of ingesting it redacted.


Runnable Example: Musterbank Guarded RAG

The walkthrough above shows the flow conceptually. The repository ships a complete, runnable version at examples/rag-python/ that drives the real /v1/rag/* endpoints over raw HTTP. It builds an internal support copilot for Musterbank AG, a fictional German retail bank, whose knowledge base mixes three sensitivity tiers, and it proves that raw PII never reaches the vector store or the LLM.

The five stages map onto the RAG endpoints:

ingest  ->  chunk + tokenize each document, store the safe chunks in the vector DB
query   ->  tokenize the agent's question for vector search
context ->  bind retrieved chunks to the query identity AND enforce access control
answer  ->  restore the LLM's token-only answer, once per output channel
delete  ->  purge the sealed session when the case is closed

Prerequisites

This example needs a running OGuardAI runtime. It tokenizes the customer name and restores the customer identity in the final answer, so it needs the Python NER detector sidecar (needs-NER). Structured PII (email, phone, IBAN, date of birth, SSN) is caught by the builtin regex detectors alone; person names and the restored identity in the answer require NER. Run the server on its own and person tokens are missing while structured PII stays protected.

Install the one dependency from the example directory examples/rag-python/:

pip install -r requirements.txt

examples/rag-python/requirements.txt:

requests>=2.31,<3

Start the OGuardAI runtime. The full stack brings up the server plus the Python NER detector; the compose path is relative to examples/rag-python/:

# Full stack (server + Python NER detector)
GUARDAI_SESSION_SECRET=$(openssl rand -base64 32) \
  docker compose -f ../../deploy/docker/docker-compose.yml up -d

# Or the server alone (structured PII only, person tokens will be missing). The shipped config binds
# loopback under dev auth, so both env vars make the mapped port reachable.
docker run -p 3000:3000 -e GUARDAI_HOST=0.0.0.0 -e GUARDAI_ALLOW_DEV_NON_LOOPBACK=1 \
  ghcr.io/oronts/oguardai/server:0.1.0

Configuration

Configuration is read from the environment:

  • GUARDAI_BASE_URL (or GUARDAI_URL / GUARDAI_BASE), default http://localhost:3000.
  • GUARDAI_API_KEY, sent as X-API-Key when the server enforces auth.
  • GUARDAI_POLICY (default enterprise) and GUARDAI_LANG (default de).
  • OPENAI_API_KEY, optional. It only flips the drafter label; the provider is never contacted, so the example runs without network egress. In production the stand-in is the single LLM call and it only ever sees the safe query and the safe context.

Run

From examples/rag-python/:

export GUARDAI_BASE_URL="http://localhost:3000"
export GUARDAI_API_KEY="<key>"          # only if the server enforces auth
python main.py

The knowledge base

The knowledge base is catalogued in kb/manifest.json with a classification per document:

DocumentClassificationContents
refund-policy-de.mdpublicThe refund policy. No PII.
dispute-runbook-de.mdinternalThe dispute runbook, with an internal escalation contact.
case-erika-mueller.txtconfidentialA customer case file: name, email, phone, IBAN, customer number, date of birth, and a US SSN a customer pasted by mistake.

examples/rag-python/kb/manifest.json:

[
  {
    "file": "refund-policy-de.md",
    "doc_id": "KB-REFUND-POLICY",
    "classification": "public",
    "owner": "policy-team",
    "tags": ["refund", "policy"]
  },
  {
    "file": "dispute-runbook-de.md",
    "doc_id": "KB-DISPUTE-RUNBOOK",
    "classification": "internal",
    "owner": "support-ops",
    "tags": ["refund", "runbook"]
  },
  {
    "file": "case-erika-mueller.txt",
    "doc_id": "CASE-2026-4471",
    "classification": "confidential",
    "owner": "support-agent-42",
    "tags": ["refund", "case"]
  }
]

examples/rag-python/kb/refund-policy-de.md (public, no PII):

# Rueckerstattungsrichtlinie (oeffentlich)

Rueckerstattungen werden innerhalb von fuenf Bankarbeitstagen nach Freigabe auf das
hinterlegte Konto ausgezahlt. Bei einer nachgewiesenen Fehlbuchung genuegt die
Angabe der Vorgangsnummer; ein separater Nachweis ist nicht erforderlich.

Betraege ueber 1.000 EUR erfordern eine Zweitfreigabe durch die Teamleitung. Die
Kundin oder der Kunde wird nach Auszahlung automatisch per gesichertem Posteingang
informiert.

examples/rag-python/kb/dispute-runbook-de.md (internal, carries the escalation contact):

# Runbook: Rueckerstattung bearbeiten (intern)

1. Vorgang im Kernbanksystem oeffnen und den Status pruefen.
2. Bei einer Fehlbuchung die Korrektur anstossen und die Auszahlung auf die
   hinterlegte IBAN freigeben.
3. Eskalation bei Betraegen ueber 1.000 EUR an die Teamleitung unter
   eskalation@musterbank.example, Durchwahl 089 5550 4400.
4. Kundenkontakt ausschliesslich ueber den gesicherten Posteingang. Kontodaten
   werden niemals telefonisch bestaetigt.

examples/rag-python/kb/case-erika-mueller.txt (confidential, all PII fictional):

Vorgangsakte (vertraulich)

Kundin: Erika Mustermann
Kundennummer: KDN-4471-2026
Kontakt: erika.mustermann@example.de, Telefon 0170 5551234
Geburtsdatum: 04.03.1987

Vorgang: Rueckerstattung einer doppelten Abbuchung ueber 189,00 EUR.
Auszahlung auf IBAN DE97 9900 0000 0123 4567 89.
Status: freigegeben am 2026-07-06, Auszahlung veranlasst.

Hinweis: Die Kundin nannte zur Legitimation ungefragt eine US-Sozialversicherungs-
nummer 123-45-6789. Diese gehoert nicht in die Akte und darf kein Modell erreichen.

The pipeline code

The full pipeline is examples/rag-python/main.py: five stages, the two-clearance scenario, and the fail-closed proof. It is shown here in file order, split into sections.

Configuration and the leak sentinels, from examples/rag-python/main.py:

"""Guarded RAG support copilot for Musterbank AG (a fictional German retail bank),
built on OGuardAI's /v1/rag/* endpoints (raw HTTP).

THE PROBLEM. An internal support copilot answers agent questions from a knowledge base
that mixes three sensitivity tiers: a public refund policy, an internal dispute runbook,
and a confidential customer case file full of regulated PII (name, email, phone, IBAN,
customer number, date of birth, and a national ID a customer pasted by mistake). A naive
RAG stack embeds every document and lets vector search return whatever is on topic. The
vector store has no notion of who is asking, so an over-permissioned retrieval hands the
LLM confidential rows it was never cleared to see. Sending that raw is a GDPR incident;
stripping it leaves the copilot unable to answer a real customer question.

THE PIPELINE (Detect -> Tokenize -> [LLM] -> Rehydrate), mapped to the RAG endpoints:

    ingest   -> chunk + tokenize each document; store the safe chunks in the vector DB
    query    -> tokenize the agent's question before it hits vector search
    context  -> bind the retrieved chunks to the query identity AND enforce row-level
                access control at the trust boundary (this is the load-bearing step)
    answer   -> restore the LLM's token-only answer per output channel
    delete   -> purge the sealed session when the case is closed (data minimization)

WHY THE ACCESS CONTROL LIVES HERE. Retrieval is topic-based and clearance-blind: it
returns every refund-tagged chunk. OGuardAI's `access_level` filter at the context stage
drops any chunk whose classification exceeds the caller's clearance BEFORE it reaches the
LLM, so the confidential case never enters the prompt for an agent with only internal
clearance. Missing classifications fail closed (treated as restricted). That is defense in
depth your vector store cannot give you.

Cross-document token identity is kept stable by a shared `corpus_id`: the same customer
maps to the same token in the case file and in the query, so retrieval and generation stay
coherent and the final answer restores the right person.

Prerequisites:
    pip install -r requirements.txt
    # OGuardAI server with the NER detector (person/company/location), e.g.:
    #   docker compose -f ../../deploy/docker/docker-compose.yml up -d
    # Structured PII (email, phone, IBAN, DOB, SSN) works on the builtins alone; person
    # names and the customer identity in the answer need NER.

Usage:
    export GUARDAI_BASE_URL="http://localhost:3000"   # or GUARDAI_URL / GUARDAI_BASE
    export GUARDAI_API_KEY="<key>"                     # only if the server enforces auth
    export OPENAI_API_KEY="<key>"                      # optional; only flips the label of
                                                       # the drafter. The provider is never
                                                       # contacted by this example.
    python main.py
"""

from __future__ import annotations

import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path

import requests

# Accept whichever base-URL var is set so the example drops into any of the repo's env
# conventions without edits.
BASE = (
    os.environ.get("GUARDAI_BASE_URL")
    or os.environ.get("GUARDAI_URL")
    or os.environ.get("GUARDAI_BASE")
    or "http://localhost:3000"
).rstrip("/")
API_KEY = os.environ.get("GUARDAI_API_KEY", "")

# One vertical policy governs the whole pipeline: the `enterprise` policy tokenizes every
# direct identifier (person, email, phone, address, customer_id, order, DOB), caps the IBAN
# at masked so an account number is never echoed in full, and hard-redacts SSN, passport,
# and credit-card values so a mistakenly pasted national ID can never become a restorable
# token. It also exposes safe metadata (gender, formality, language) for correct phrasing.
POLICY = os.environ.get("GUARDAI_POLICY", "enterprise")
LANG = os.environ.get("GUARDAI_LANG", "de")

# A shared corpus id gives the same value the same token across every ingest and the query.
CORPUS = "aurelis-support-kb"

HEADERS = {"Content-Type": "application/json"}
if API_KEY:
    HEADERS["X-API-Key"] = API_KEY

# Presence of a provider key only flips the printed label; the drafter is a deterministic
# offline stand-in so the example runs without network egress. See draft_answer().
LLM_LABEL = "OpenAI (token-only)" if os.environ.get("OPENAI_API_KEY") else "offline template drafter"

HERE = Path(__file__).resolve().parent
KB = HERE / "kb"
TOKEN_RE = re.compile(r"\{\{([^}]+)\}\}")

# Raw sentinels from the confidential case file and the internal runbook, split by which detection
# tier masks them. The STRUCTURED values are caught by the builtin regex detectors on every lane
# (the SSN is hard redacted), so any one crossing to the vector store or the model is a hard
# failure. The customer name needs NER; whether a raw name crossing is a failure or a noted
# degradation is decided by ner_active, not by inspecting the output. The bare case number matches
# no builtin detector, so it is not sentineled here and is never claimed protected.
STRUCTURED_MARKERS = {
    "email erika.mustermann@example.de": "erika.mustermann@example.de",
    "email eskalation@musterbank.example": "eskalation@musterbank.example",
    "phone 0170 5551234": "0170 5551234",
    "phone 089 5550 4400": "089 5550 4400",
    "dob 04.03.1987": "04.03.1987",
    "iban DE97 9900 0000 0123 4567 89": "DE97 9900 0000 0123 4567 89",
    "ssn 123-45-6789": "123-45-6789",
}
NAME_MARKERS = ("Erika Mustermann",)

Fail-closed HTTP and the token helpers:

class GuardError(RuntimeError):
    """An OGuardAI request that did not succeed. The pipeline fails closed: on any error we
    stop rather than fall back to embedding, retrieving, or answering on raw PII."""

    def __init__(self, code: str, message: str, path: str) -> None:
        super().__init__(f"{code} at {path}: {message}")
        self.code = code


def post(path: str, body: dict) -> dict:
    resp = requests.post(f"{BASE}{path}", json=body, headers=HEADERS, timeout=60)
    if resp.status_code >= 400:
        detail = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
        code = detail.get("code", f"HTTP_{resp.status_code}")
        # Never fall back to the raw upstream body: it can echo request PII. Keep OGuardAI's own
        # structured error string when present, else a generic status-only message.
        error = detail.get("error")
        message = error if isinstance(error, str) else f"OGuardAI request failed (HTTP {resp.status_code})"
        raise GuardError(code, message, path)
    return resp.json()


# --- Token helpers --------------------------------------------------------------------

def token_type(inner: str) -> str:
    """Type key from a token body: 'person:p_001:hash' -> 'person'."""
    return inner.rsplit(":", 2)[0] if inner.count(":") >= 2 else inner.split(":")[0]


def token_map(safe_text: str) -> dict[str, str]:
    """Map each entity type to the EXACT token string (hash included) the model must echo.
    Short forms like {{person:p_001}} do not resolve; the hash is load-bearing."""
    out: dict[str, str] = {}
    for inner in TOKEN_RE.findall(safe_text):
        out.setdefault(token_type(inner), "{{" + inner + "}}")
    return out

Ingest (stage 1) and the vector-store stand-in:

# --- Knowledge base + ingest ----------------------------------------------------------

@dataclass
class StoredChunk:
    """One tokenized chunk as it would sit in a vector store: the safe text (embedded and
    searched), its source classification, and the sealed per-chunk session that lets the
    context stage restore this chunk's tokens later."""

    doc_id: str
    classification: str
    safe_text: str
    chunk_session_state: str


def load_manifest() -> list[dict]:
    return json.loads((KB / "manifest.json").read_text(encoding="utf-8"))


def ingest_all() -> list[StoredChunk]:
    """Stage 1: chunk and tokenize every catalogued document, carrying its classification
    into OGuardAI as document metadata. Only tokenized chunks leave the boundary; the raw
    files never reach the vector store."""
    stored: list[StoredChunk] = []
    for entry in load_manifest():
        text = (KB / entry["file"]).read_text(encoding="utf-8")
        res = post(
            "/v1/rag/ingest",
            {
                "text": text,
                "corpus_id": CORPUS,
                "policy": POLICY,
                "language": LANG,
                "chunking_strategy": "paragraph",
                "document_metadata": {
                    "document_id": entry["doc_id"],
                    "classification": entry["classification"],
                    "owner": entry["owner"],
                    "tags": entry["tags"],
                },
            },
        )
        for chunk in res["chunks"]:
            stored.append(
                StoredChunk(
                    doc_id=entry["doc_id"],
                    classification=entry["classification"],
                    safe_text=chunk["safe_text"],
                    chunk_session_state=chunk["chunk_session_state"],
                )
            )
    return stored


# --- Retrieval (a stand-in for the vector store) --------------------------------------

_TOPIC_TERMS = ("rueckerstattung", "erstattung", "fehlbuchung", "refund", "rueckzahlung")


def retrieve(stored: list[StoredChunk], safe_query: str) -> list[StoredChunk]:
    """Simulated vector search. It is clearance-blind on purpose: it returns every chunk
    that is on topic, exactly like a real ANN index that has no row-level security. The
    access decision is deferred to the context stage, where OGuardAI can enforce it at the
    trust boundary. Matching is on the shared topic terms and on any token the query and a
    chunk have in common (cross-document identity via the shared corpus)."""
    query_tokens = set(TOKEN_RE.findall(safe_query))
    hits: list[StoredChunk] = []
    for chunk in stored:
        hay = chunk.safe_text.lower()
        on_topic = any(term in hay for term in _TOPIC_TERMS)
        shares_identity = bool(query_tokens & set(TOKEN_RE.findall(chunk.safe_text)))
        if on_topic or shares_identity:
            hits.append(chunk)
    return hits

The LLM stand-in and stages 2 to 5 (query, context, answer, delete):

# --- The LLM stand-in -----------------------------------------------------------------
# In production this is ONE call to your chat model. It reads OPENAI_API_KEY exactly like a
# real client would and is handed only the safe query and the safe context, so it sees
# tokens and safe metadata, never raw PII. To keep the example deterministic and offline it
# composes a token-only German answer from the retrieved chunks; the provider is never
# contacted here.

def draft_answer(safe_chunks: list[str]) -> str:
    """Build a token-only answer that echoes the exact tokens the rehydrator will resolve.
    When a confidential case chunk is present its person and IBAN tokens are cited; when the
    caller lacks clearance those chunks were already filtered out, so the answer stays on the
    general policy and carries no customer token."""
    joined = " ".join(safe_chunks)
    tok = token_map(joined)
    person = tok.get("person")
    iban = tok.get("iban")

    lead = f"Guten Tag {person}," if person else "Guten Tag,"
    policy_line = ("Rueckerstattungen werden innerhalb von fuenf Bankarbeitstagen nach "
                   "Freigabe ausgezahlt.")
    if person and iban:
        case_line = (f" Die Rueckerstattung fuer {person} ist freigegeben und wird auf "
                     f"{iban} ausgezahlt.")
    elif person:
        case_line = f" Der Vorgang fuer {person} ist freigegeben."
    else:
        case_line = (" Fuer kundenbezogene Details ist eine hoehere Freigabestufe "
                     "erforderlich.")
    return f"{lead} {policy_line}{case_line}"


# --- Pipeline stages 2-5 --------------------------------------------------------------

def run_query(question: str) -> dict:
    """Stage 2: tokenize the agent's question before vector search."""
    return post("/v1/rag/query", {"query": question, "corpus_id": CORPUS, "policy": POLICY, "language": LANG})


def run_context(retrieved: list[StoredChunk], query_state: str, access_level: str) -> dict:
    """Stage 3: bind the retrieved chunks to the query identity and enforce access control.
    `access_level` is the caller's clearance; any chunk classified above it is filtered out
    before the LLM sees it. `document_sessions` threads each chunk's ingest session so the
    surviving chunks can be restored in the final answer."""
    return post(
        "/v1/rag/context",
        {
            "chunks": [c.safe_text for c in retrieved],
            "chunk_classifications": [c.classification for c in retrieved],
            "document_sessions": [c.chunk_session_state for c in retrieved],
            "access_level": access_level,
            "session_state": query_state,
            "corpus_id": CORPUS,
            "policy": POLICY,
            "language": LANG,
        },
    )


def answer_per_channel(answer: str, context_state: str) -> dict[str, dict]:
    """Stage 4: restore the token-only answer once per output channel, from ONE sealed
    session. The channel selects the restore rule; the policy is the ceiling, so a channel
    can only tighten what the base rule allows, never widen it."""
    views: dict[str, dict] = {}
    for channel in ("user_output", "export", "log_safe"):
        res = post("/v1/rag/answer", {"answer": answer, "session_state": context_state, "output_channel": channel})
        views[channel] = res
    return views


def teardown(context_state: str) -> dict:
    """Stage 5: purge the sealed session. Closing the case invalidates the token map so no
    residual mapping can be replayed later. This is the data-minimization / right-to-erasure
    handle for a per-request RAG session."""
    return post("/v1/rag/delete", {"session_state": context_state})

The boundary guard that asserts no raw PII crosses to the store or the model:

# --- Boundary guard -------------------------------------------------------------------

def fetch_ner_active() -> bool:
    """The one leak-exemption signal: whether this runtime is configured to run NER, read from
    /v1/capabilities. If it cannot be read, treat NER as available so a raw customer name is a
    hard failure rather than a silently excused leak."""
    try:
        resp = requests.get(f"{BASE}/v1/capabilities", headers=HEADERS, timeout=10)
        if resp.status_code >= 400:
            return True
        val = resp.json().get("ner_active", True)
        return val is not False
    except (requests.RequestException, ValueError):
        return True


def check_boundary(where: str, text: str, ner_available: bool) -> list[str]:
    """Guard one string that crosses to the vector store or the model. Any structured marker
    present is a leak that fails on every lane. A raw name fails only when the runtime runs NER;
    otherwise it is a printed note, never silent. Returns the name notes recorded, if any."""
    leaked = sorted(label for label, raw in STRUCTURED_MARKERS.items() if raw in text)
    if leaked:
        raise SystemExit(f"FAIL: structured PII crossed to {where}: {', '.join(leaked)}")
    notes: list[str] = []
    for name in NAME_MARKERS:
        if name in text:
            if ner_available:
                raise SystemExit(
                    f"FAIL: the name {name} crossed to {where}; "
                    "this runtime runs NER, so names must be tokenized"
                )
            print(f"   NOTE: the name {name} crossed to {where}; this runtime runs without NER, "
                  "so names are not tokenized. Start the full stack for name-level protection.")
            notes.append(name)
    return notes

The scenario driver, the fail-closed proof, and main:

# --- Scenario driver ------------------------------------------------------------------

def rule(title: str) -> None:
    print("=" * 82)
    print(title)
    print("=" * 82)


def run_scenario(stored: list[StoredChunk], question: str, *, clearance: str, label: str,
                 ner_available: bool) -> list[str]:
    """One agent, one clearance, end to end. Prints what left the boundary at each stage so
    the trust boundary is auditable: the safe context the LLM saw, the filtered chunk count,
    the token-only draft, and the answer restored per channel. Returns the name notes recorded."""
    rule(f"AGENT CLEARANCE = {clearance.upper()}  ({label})")
    notes: list[str] = []

    q = run_query(question)
    print(f"   safe query (search): {q['safe_query']}")
    notes += check_boundary("the vector search", q["safe_query"], ner_available)

    retrieved = retrieve(stored, q["safe_query"])
    print(f"   retrieved (topic match, clearance-blind): {len(retrieved)} chunk(s) "
          f"from {sorted({c.doc_id for c in retrieved})}")

    ctx = run_context(retrieved, q["session_state"], clearance)
    kept = ctx["safe_chunks"]
    print(f"   access filter      : {ctx.get('chunks_filtered', 0)} chunk(s) above '{clearance}' "
          f"dropped at the boundary, {len(kept)} passed to the LLM")
    for c in kept:
        print(f"     safe context     : {c}")
        notes += check_boundary("the retrieved context", c, ner_available)

    draft = draft_answer(kept)
    print(f"\n   drafting with      : {LLM_LABEL}")
    print(f"   token-only answer  : {draft}")
    notes += check_boundary("the token-only answer", draft, ner_available)

    views = answer_per_channel(draft, ctx["session_state"])
    print("\n   restored per output channel (one sealed session):")
    print(f"     agent (user_output): {views['user_output']['restored_answer']}")
    print(f"     analytics (export) : {views['export']['restored_answer']}")
    print(f"     audit (log_safe)   : {views['log_safe']['restored_answer']}")
    resolved = views["user_output"].get("tokens_resolved")
    unresolved = views["user_output"].get("tokens_unresolved") or []
    print(f"     tokens_resolved={resolved}  tokens_unresolved={len(unresolved)}")

    purge = teardown(ctx["session_state"])
    print(f"\n   session teardown   : deleted={purge.get('deleted')} "
          f"entities_purged={purge.get('entities_purged')}\n")
    return notes


def run_fail_closed_proof() -> None:
    """A corrupted session must not leak. rag/answer on a tampered blob returns a typed error
    and the pipeline stops rather than emitting an unresolved or guessed answer."""
    rule("FAIL-CLOSED PROOF  (a tampered session must not resolve)")
    try:
        post("/v1/rag/answer", {"answer": "Guten Tag {{person:p_001:deadbeef}}.", "session_state": "tampered-session-blob"})
        raise SystemExit("fail-closed check failed: a tampered session resolved an answer")
    except GuardError as exc:
        print(f"   rejected with {exc.code}; no answer was produced.\n")


def main() -> None:
    try:
        requests.get(f"{BASE}/v1/health", headers=HEADERS, timeout=10).raise_for_status()
    except requests.RequestException as exc:
        raise SystemExit(f"Cannot reach OGuardAI at {BASE}: {exc}")

    ner_available = fetch_ner_active()
    if not ner_available:
        print("WARNING: NER is not active on this runtime; person tokens and the restored "
              "customer identity in the answer will be missing. Structured PII (email, phone, "
              "IBAN, DOB, SSN) still works. Start the full stack via docker compose.\n",
              file=sys.stderr)

    print(f"Server: {BASE}  policy={POLICY}  corpus={CORPUS}  drafter={LLM_LABEL}\n")

    name_notes: list[str] = []
    try:
        rule("1. INGEST  (chunk + tokenize the catalogued knowledge base)")
        stored = ingest_all()
        for c in stored:
            print(f"   [{c.classification:12}] {c.doc_id}: {c.safe_text.strip()[:96]}"
                  f"{'...' if len(c.safe_text.strip()) > 96 else ''}")
        print()
        for c in stored:
            name_notes += check_boundary(f"the vector store ({c.doc_id})", c.safe_text, ner_available)

        question = "Wie ist der Stand der Rueckerstattung fuer Erika Mustermann?"
        # Same question, two clearances. Internal cannot see the confidential case; the
        # customer's PII never enters the prompt. Confidential can, and the answer restores it.
        name_notes += run_scenario(stored, question, clearance="internal",
                                   label="support agent, standard desk", ner_available=ner_available)
        name_notes += run_scenario(stored, question, clearance="confidential",
                                   label="case handler on this file", ner_available=ner_available)
        run_fail_closed_proof()
    except GuardError as exc:
        raise SystemExit(f"OGuardAI request failed: {exc}")

    if name_notes:
        unique = ", ".join(sorted(set(name_notes)))
        print("Done. The detectable structured PII (email, phone, IBAN, DOB, SSN) stayed inside "
              "the runtime and confidential rows never crossed a clearance they were not entitled "
              f"to; the customer name(s) {unique} reached the vector store and the model because "
              "this runtime runs without NER. Start the full stack for name-level protection.")
    else:
        print("Done. The detectable structured PII (email, phone, IBAN, DOB, SSN) stayed inside "
              "the trusted zone as tokens; the vector store, the model, and every downstream sink saw "
              "them as tokens only, and confidential rows never crossed a clearance they were not "
              "entitled to.")
    print("Note: the case file's bare customer number carries no prefix the builtin detector "
          "matches, so it is below the detection floor and is not tokenized or claimed protected.")


if __name__ == "__main__":
    main()

Endpoints used

StageEndpointInOut
IngestPOST /v1/rag/ingesttext, document_metadata, corpus_id, policychunks[].safe_text, chunks[].chunk_session_state
QueryPOST /v1/rag/queryquery, corpus_id, policysafe_query, session_state
ContextPOST /v1/rag/contextchunks, chunk_classifications, document_sessions, access_level, session_statesafe_chunks, session_state, chunks_filtered
AnswerPOST /v1/rag/answeranswer, session_state, output_channelrestored_answer, tokens_resolved
DeletePOST /v1/rag/deletesession_statedeleted, entities_purged

Expected output

The exact token hashes, IBAN mask length, and chunk counts vary per run and with the chunking strategy; the structure does not.

1. INGEST  (chunk + tokenize the catalogued knowledge base)
   [public      ] KB-REFUND-POLICY: Rueckerstattungen werden innerhalb von fuenf Bankarbeitstagen ...
   [internal    ] KB-DISPUTE-RUNBOOK: ... Eskalation ... an {{email:e_001:...}}, Durchwahl {{phone:ph_001:...}}.
   [confidential] CASE-2026-4471: Kundin {{person:p_001:...}} ... IBAN {{iban:ib_001:...}} ... [REDACTED:ssn] ...

==================================================================================
AGENT CLEARANCE = INTERNAL  (support agent, standard desk)
==================================================================================
   safe query (search): Wie ist der Stand der Rueckerstattung fuer {{person:p_001:...}}?
   retrieved (topic match, clearance-blind): chunk(s) from ['CASE-2026-4471', 'KB-DISPUTE-RUNBOOK', 'KB-REFUND-POLICY']
   access filter      : the confidential CASE-2026-4471 chunk(s) dropped at the boundary, public + internal passed to the LLM
   token-only answer  : Guten Tag, Rueckerstattungen werden ... Fuer kundenbezogene Details ist eine hoehere Freigabestufe erforderlich.
   restored per output channel (one sealed session):
     agent (user_output): Guten Tag, Rueckerstattungen werden innerhalb von fuenf Bankarbeitstagen ...
   session teardown   : deleted=True entities_purged=...

==================================================================================
AGENT CLEARANCE = CONFIDENTIAL  (case handler on this file)
==================================================================================
   access filter      : nothing above 'confidential' dropped, every retrieved chunk passed to the LLM
   token-only answer  : Guten Tag {{person:p_001:...}}, ... wird auf {{iban:ib_001:...}} ausgezahlt.
   restored per output channel (one sealed session):
     agent (user_output): Guten Tag Erika Mustermann, ... wird auf D*** **** **** **** **** *0 ausgezahlt.
     analytics (export) : Guten Tag (person on file), ... auf (iban on file) ...
     audit (log_safe)   : Guten Tag [REDACTED], ... auf [REDACTED] ...

FAIL-CLOSED PROOF  (a tampered session must not resolve)
   rejected with GUARDAI_...; no answer was produced.

What the example proves

  1. Access control at the trust boundary, not in the vector store. Retrieval is topic-based and clearance-blind, exactly like a real ANN index that has no row-level security: it returns every refund-tagged chunk. OGuardAI's access_level filter at the context stage drops any chunk classified above the caller's clearance before the LLM sees it. The example runs the same question twice. With clearance internal the confidential case is filtered out, so the customer's PII never enters the prompt and the copilot answers on the general policy only. With clearance confidential the case passes, and the answer restores the customer's identity. Missing classifications fail closed (treated as restricted), so an uncatalogued chunk is dropped rather than leaked. Access levels order as public < internal < confidential < restricted.
  2. Cross-document token identity. A shared corpus_id gives the same customer the same token in the case file and in the query, so retrieval and generation stay coherent and the final answer restores the right person.
  3. The full Detect, Tokenize, [LLM], Rehydrate flow. Documents and the query are tokenized before anything reaches the model; the LLM stand-in drafts on tokens only; the answer is restored afterwards.
  4. One answer, three output channels, one sealed session. user_output for the agent, export for the analytics pipeline, log_safe for the immutable audit line. The channel selects the restore rule; the policy is the ceiling.
  5. Session teardown. rag/delete purges the sealed session when the case is closed, the data-minimization handle for a per-request RAG session.
  6. Fail-closed error handling. Any runtime error stops the pipeline instead of falling back to raw text, and a tampered session makes rag/answer return a typed error rather than a guessed answer.