OGuardAI
Guides

Extending Entities

Add custom entity types to OGuardAI in policy YAML, no code or fork required

Overview

OGuardAI recognizes entity types in three groups. The built-in engine detects the named regex formats (Email, Phone, CustomerId, Order, Address, Iban, Ssn, Ip, Url, CreditCard, DateOfBirth, Passport, VatId, HealthId) and a set of built-in custom patterns (money amounts, the German tax id, the German social-security number, quote ids, case references). Person, Company, and Location come from the Python NER sidecar. None of these are a fixed ceiling: a deployment adds its own types in policy YAML, and the detector floor itself is extensible.

You almost never touch any of that to add your own type. A deployment defines its own domain identifiers, MRN, Aktenzeichen, NPI, loyalty number, clearance code, contract number, entirely in policy YAML with custom_patterns. No code, no fork, no recompile. That is the supported path and the one this guide leads with. Editing Rust is reserved for contributing a detector to the shipped built-in floor (the last section).

Add a custom entity type in policy YAML (no code)

custom_patterns declares a domain identifier. Each match becomes {{custom:<type>:id}} and is governed by a normal policy rule. It is additive: a custom pattern can only ADD detections, never disable or weaken a built-in one.

# policies/acme/policy.yaml
custom_patterns:
  - entity_type: "mrn"                                  # your type name, lowercase_with_underscores
    pattern: '\bMRN[-:\s]?\d{6,10}\b'                   # Rust regex; linear-time, bounded quantifiers only, validated at load
    confidence: 0.9
    context_words: ["mrn", "medical record", "patient"] # nearby words raise confidence

rules:
  - entity_type: "mrn"            # reference it by the same bare name
    protection_level: 1
    action: "tokenize"
    restore_mode: "masked"
    conditions: []

That is the whole change. Restart the server and MRN-04417829 tokenizes as {{custom:mrn:x_001:42f5f25fb8cc}}; the model never sees the value, and it restores per the rule's restore_mode and the resolved output channel.

Pattern rules: bounded and discriminating

Two rules are checked when a pattern compiles. Breaking either one aborts the load (or returns 400 from POST /v1/patterns): the pattern is refused outright rather than accepted and quietly weakened.

1. The match length must be bounded. An open-ended quantifier (+, *, or {n,}) is refused, because a match with no upper bound cannot be held back while it assembles:

RefusedWrite instead
TKT-[0-9]+TKT-[0-9]{1,10}
(?i)\bQ-\d{4}-\d{3,}\b(?i)\bQ-\d{4}-\d{3,6}\b
CEP[::\s]*\d{5}-\d{3}CEP[::]?\s*\d{5}-\d{3}

This is a trust-boundary property, not a performance knob. When a response is streamed, the PII guard withholds a trailing window of at most 8192 bytes while it waits for the whole of a possible entity to arrive, and only then masks it. A match that can be longer than that window cannot be withheld, so the leading bytes of a real value would already be on the wire before the span was assembled and could be masked. The runtime refuses such a pattern rather than accept one whose matches it cannot protect. The same 8192 bytes cap the span an external detector (the NER sidecar or a detector.webhook service) may return; a longer one is rejected for the same reason.

The length is measured on normalized text, where a run of whitespace is already collapsed to one separator. \s* and \s+ between two parts of a pattern are therefore bounded and accepted; a class that mixes whitespace with other characters ([:\s]*) is not, because the colons do not collapse. A context_required pattern spends match length plus context_window_chars from the same 8192 bytes, since the cue has to stay in the window too. The pattern source itself is capped at 2000 bytes, and one resolved policy may declare at most 128 custom patterns.

2. The pattern must discriminate. A pattern that matches the empty string, or that matches arbitrary text (.*, \w+, \p{L}+, or a bare \d{1,10} that matches any number), is refused: it would turn detection into a blanket redactor and strip the context the model needs. Anchor on a literal or on a shape specific to your identifier, the way MRN[-:\s]?\d{6,10} does.

Both rules apply everywhere a custom pattern is declared: policy custom_patterns, per-request custom_patterns on transform and detect, and detector.custom_detectors or detector.vocab.extra_patterns / extra_pattern_specs in oguardai.yaml. To check one before deploying it, send it to POST /v1/patterns, which compiles it through the same path and returns the reason it was refused. That reason names the entity type and the rule, never the pattern text, because an operator-authored regex can itself carry a literal value.

Tokenize only part of the match (value_group)

By default the whole match is the entity. To match on a label but protect ONLY the identifier, set value_group to the capture group holding the value. This is how you catch a bare identifier (a bank account, a routing number) that a plain regex would otherwise over-match on every long number: anchor on the label, tokenize just the group.

custom_patterns:
  - entity_type: "bank_account"
    pattern: '(?i)\b(?:account|acct|konto)[ \t:#.-]{0,4}(\d{8,17})\b'
    value_group: 1                # tokenize only capture group 1 (the number, not the label)
    context_words: ["account", "acct", "konto"]

account 0532013000 tokenizes only 0532013000 (the word account stays for model context), and a bare number with no label is not matched. value_group is validated against the pattern's capture-group count at startup (out of range aborts the load, fail closed); omit it to tokenize the whole match.

Require a nearby label (context_required)

value_group embeds the label in the regex. When you would rather keep a broad, readable pattern (an ICD-10 or SWIFT/BIC shape) but still avoid matching every lookalike token, set context_required: true. A match is then kept ONLY when one of context_words appears in the context_window_chars immediately before it; otherwise it is dropped. It turns the confidence boost into a gate.

custom_patterns:
  - entity_type: "icd10"
    pattern: '\b[A-TV-Z][0-9][0-9AB](?:\.[0-9A-TV-Z]{1,4})?\b'
    context_words: ["icd", "diagnosis", "diagnose", "dx"]
    context_required: true          # keep a code only near a diagnosis label
    context_window_chars: 40        # optional; defaults to the built-in window

Diagnose E11.9 und I10 tokenizes both codes (the list stays inside the window), while a bare A12 in an unrelated sentence is left alone. This is opt-in: the default (context_required: false) leaves matching exactly as before, and the gate only ever narrows one of your own custom patterns. It never applies to a built-in type, so it can never weaken the email, IBAN, or SSN floor. context_required: true with no context_words, or a window outside [1, 4096], aborts the load (fail closed).

Every entity_type you reference must be a built-in type or a custom type declared in custom_patterns of the same policy; an unknown label is treated as a typo and aborts startup (fail closed), so a mistyped ssn can never silently leave SSNs unprotected. To reference a custom type detected by a deployment-level detector (not declared in this policy), write it as custom:<name> to confirm intent and skip the typo check.

Working examples ship for every vertical: policies/templates/healthcare-hipaa (mrn, npi, dea), policies/financial (bank_account, routing_number), policies/government (clearance_id, facility_code), policies/hr (employee_id), policies/legal-privilege (matter_id).

Restore your custom type (no code)

Custom entities restore through the same six modes (full, partial, masked, formatted, abstract, none) as built-ins, plus config-driven restore strategies, all in YAML:

  • Partial with a strategy. For last-N restoration (a card, an account, an MRN) or numeric generalization (a salary band), add a restore_strategies block. It applies only when the entity resolves at partial for the channel, so it can never exceed the channel ceiling, and masks the whole value if it cannot produce a bounded result (fail closed).
restore_strategies:
  credit_card:
    kind: reveal_last_n   # **** **** **** 4821
    n: 4
  salary:
    kind: bucket          # generalize a number into a coarse band
    ranges:
      - {max: 50000, label: "band: under 50k"}
      - {label: "band: 50k and up"}

Bucket ranges are validated at load and fail closed: max values must strictly ascend, only the last range may omit max (an open range), and a label must not be a bare number ("75000", "75.000", "50,000" are rejected so a band can never disclose the exact figure). Use a worded band such as "under 50k".

  • Formatted / abstract labels come from the restore-template floor (crates/rehydrate/data/restore_floor.yaml, 30 languages) and per-deployment overrides via restore_templates / GUARDAI_RESTORE_TEMPLATES: config, not code. A custom type with no specific label falls back to a safe generic label.

Semantic types regex cannot catch (NER labels, no code)

Identifiers that are not a fixed format (a diagnosis, a medication, a matter subject) are caught by asking the NER sidecar for extra zero-shot labels, declared per policy:

detection:
  required_for: ["diagnosis"]          # fail closed if NER is down
  ner_labels: ["medical condition"]    # extra labels to detect
  ner_label_map:
    "medical condition": "diagnosis"   # report the label as this entity type

required_for makes the type fail closed: if NER is required but the sidecar is unavailable, the request errors instead of under-detecting. Regex custom_patterns (an MRN, a contract number) do not depend on the sidecar.

What this does and does not protect (topical sensitivity)

ner_labels catches a sensitive span the model can name, such as "cancer diagnosis" or "criminal charge". It does not, on its own, protect a secret that is not a span: diffuse context spread across several sentences, a combination of otherwise-safe facts, or an implication with no keyword. The honest boundary is that OGuardAI removes the identifiers and the topics you declare, not that entity tokenization guarantees full subject-matter confidentiality. For a fixed set of sensitive phrases, declare them as ner_labels (semantic), custom_patterns (literal identifier), or topical_redaction (below, when you need the whole surrounding sentence removed, not just the phrase).

Redact a sensitive topic, not just an identifier (topical_redaction)

custom_patterns and ner_labels tokenize a matched value and keep the rest of the sentence intact. Some matter is sensitive as a topic: the diagnosis, the criminal charge, the trade secret is the secret even when every name around it is tokenized. topical_redaction is for that. Each rule declares literal phrases and/or regex patterns; a match redacts the surrounding sentence (or a character window) to a generic sentinel [REDACTED:sensitive_topic] BEFORE detection and tokenization, so the raw matter never enters the token map, the model, or rehydrate. It is non-restorable by design (a topical secret has no safe partial form).

topical_redaction:
  - name: "clinical_topic"
    phrases: ["cancer diagnosis", "HIV status"]   # matched case-insensitively, UTF-8 safe
    scope: "sentence"                              # redact the whole containing sentence
    on_match: "continue"                           # redact and keep processing (default)
  - name: "matter_code"
    patterns: ['\bMATTER-\d{4,6}\b']               # linear-time regex, compiled at load
    scope: "window"
    window_chars: 30                               # redact 30 chars on each side of the match
  - name: "classified_block"
    phrases: ["classified"]
    on_match: "reject"                             # deny the whole request (403) instead

It is additive, fail-closed, and deterministic:

  • Additive / tighten-only. A rule can only ADD redaction; it never weakens the built-in entity floor or an entity rule. Inheritance unions rules across the chain (a child policy may add rules, never remove a parent's).
  • Every ingress. The pass runs on text / JSON / chat transform, /v1/detect, batch, RAG (the full document is redacted before chunking, so a phrase cannot be split across a chunk boundary), and image OCR (overlapping words are blacked out), plus a post-rehydrate pass that strips topical matter the model itself generated.
  • Fail-closed. A rule with no phrase or pattern, an empty phrase, an invalid regex, an out-of-range window_chars, a duplicate name, or an unknown field aborts startup. on_match: reject returns 403 GUARDAI_POLICY_DENIED before any LLM-facing output.
  • Generic sentinel. The replacement never names the rule, because the topic label can itself disclose the matter.

Working example: policies/topical-demo.

What it does NOT do: guarantee full subject-matter confidentiality for diffuse facts spread across sentences, rare combinations of otherwise-safe facts, or an undeclared topic with no keyword. It removes the identifiers plus the topics you declare. For those residual contextual cases, keep the material out of the prompt at the application layer.

Transparent proxy note. topical_redaction is enforced on the direct API, the SDKs, and MCP. The transparent proxy (oguardai-proxy) has several independent content paths and does not yet apply topical redaction, so it fails closed at startup if its policy declares topical rules. Use the direct API or SDK for topical enforcement.

Protect a whole CSV column (document_columns)

A regex or NER detector catches values by their shape or meaning. A free-text column, an internal note, or a bespoke reference code often has no shape a detector can match, yet the whole column is sensitive. document_columns maps a CSV column, by header, to an entity type: every cell in that column becomes an entity of that type, so the column is protected even when nothing else matches it.

document_columns:
  - format: "csv"
    header: "employee_notes"   # matched case-insensitively, whitespace-normalized
    entity_type: "employee_note"

rules:
  - entity_type: "employee_note"  # a normal rule governs action + restore
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"

It is detection only and reuses the whole policy engine:

  • The normal rule governs. The mapped entity_type is declared like a custom_patterns type; its ordinary policy rule decides action, restore mode, and channel behavior. A column mapped to a built-in type (e.g. email) is governed by that built-in's rule.
  • Additive, built-in wins overlap. A column rule only ADDs detection. Where a cell also contains a built-in PII value (an email in a notes cell), the built-in detection wins that span and the rest of the cell is covered by the column type, so no bytes are left raw and the email keeps its own (restorable) protection.
  • CSV file uploads only. It applies to POST /v1/transform/file (and /v1/rehydrate/file) for CSV, the format that exposes stable per-cell offsets. It has no effect on text/JSON/chat transform, and DOCX/HTML/Markdown/PDF are not covered (they flatten to text without reliable cell boundaries). Working example: policies/csv-columns-demo.
  • Fail-closed. An invalid format, a blank header, an unknown field, or too many rules aborts startup; a CSV whose offsets cannot be built fails the request.

Compatibility matrix

CapabilityHow (all policy / config)
Detection (fixed format)custom_patterns.pattern (regex)
Detection (semantic)detection.ner_labels + ner_label_map
Topic redaction (sentence / window)topical_redaction (phrases / patterns)
CSV column protection (by header)document_columns (format / header / type)
Tokenization / transformAutomatic
Rehydrate full / partial / masked / noneAutomatic (restore_mode)
Rehydrate partial last-N / bucketrestore_strategies
Rehydrate formatted / abstract labelrestore-template config
Policy rules / conditions / channelsAutomatic (entity_type match)
Output guard floorAutomatic (untrusted channels masked)
RevocationAutomatic

Every row is policy or config. There is no "needs custom code" path for adding a deployment's own entity type.

End-to-end (live)

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{"input": "Patient MRN-04417829 admitted.", "policy": "acme"}'
# -> safe_text: "Patient {{custom:mrn:x_001:42f5f25fb8cc}} admitted."

Runnable example: per-request custom patterns (Python)

The policy custom_patterns above apply a domain type to every request. When the set of identifiers is large, tenant-specific, and changes often, you can instead send the patterns WITH each request. This runnable example is a fictional Munich claims bureau, Musterfirma GmbH, running one OGuardAI server for several insurance carriers: each request carries that carrier's identifier regexes as custom_patterns, so a new carrier scheme ships with its traffic and needs no policy edit, no redeploy, and no server restart. Full source: examples/custom-patterns-python/.

Scenario

A claims handler pastes an inbound first-notice-of-loss (FNOL) report into an LLM to summarize it and draft a German acknowledgment. Each report mixes two kinds of sensitive data:

  • Regulated PII the built-in detectors, NER, and the vertical policy already protect: the policyholder's name (gender-aware), email, phone, the damaged-property address, the payout IBAN, and the odd national ID a caller pasted by mistake.
  • Carrier-specific domain identifiers no built-in detector knows and that differ per carrier: the claim / Schaden number, the Versicherungsschein number, the adjuster and handler references, the broker agency. Musterversicherung Nord stamps SCH-.../VS-...; Musterversicherung Sued stamps AV-CL-.../AVP-....

A request-level custom pattern is a soft detector. Highest precedence first, the runtime resolves overlaps as documented in the example README:

real hard-ID (SSN/IBAN/passport)  >  policy hard-custom  >  built-in PII (email/address)
   >  request custom_patterns  >  NER (person/company/location)

So a request custom pattern ADDS coverage for values nothing else claims (the carrier IDs), WINS the overlap against NER (it pins a broker name NER would mislabel as a company), but never SUPPRESSES a built-in PII span. At request level context_words only BOOST confidence; the hard context gate (context_required) and hard redaction (protection_level 1) are policy-level, not per-request.

Prerequisites

Needs NER: yes. Person, company, and location detection and the German honorific restore need the NER detector sidecar. Structured PII (email, phone, IBAN, the national ID, and the carrier identifiers themselves) works on the built-ins alone, so running the server without the sidecar still protects those but omits the person tokens, the gender-aware salutation, and the broker-over-NER case.

Install the one dependency (requirements.txt is just requests>=2.31,<3):

pip install -r requirements.txt

Start the runtime with the NER detector:

# 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 and the broker-over-NER case are 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 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.
  • OPENAI_API_KEY, optional. It only flips the drafter label; the provider is never contacted, so the example runs without network egress.

Run it

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

Per-carrier registry (carriers.json)

The registry holds each tenant's policy, language, FNOL fixture, and its own custom_patterns set. This is the only thing that differs between carriers on the same server.

{
  "rheinmark": {
    "name": "Musterversicherung Nord AG",
    "policy": "german-support",
    "language": "de",
    "fnol_file": "fnol_rheinmark.txt",
    "custom_patterns": [
      { "entity_type": "claim_ref",     "pattern": "SCH-[0-9]{4}-[0-9]{1,8}\\b",              "confidence": 1.0 },
      { "entity_type": "policy_number", "pattern": "VS-[0-9]{2,6}-[0-9]{1,8}\\b",                 "confidence": 1.0 },
      { "entity_type": "adjuster_ref",  "pattern": "GUT-[0-9]{4}-[0-9]{1,8}\\b",              "confidence": 0.95 },
      { "entity_type": "handler_ref",   "pattern": "SB-[0-9]{4}-[0-9]{1,8}\\b",               "confidence": 0.95 },
      { "entity_type": "broker_agency", "pattern": "Maklerbüro ([A-Za-zÄÖÜäöüß]{2,40})\\b", "value_group": 1, "confidence": 0.95 },
      { "entity_type": "de_social_id",  "pattern": "[0-9]{2} [0-9]{6} [A-Z] [0-9]{3}\\b", "confidence": 1.0 }
    ]
  },
  "alpenverbund": {
    "name": "Musterversicherung Sued AG",
    "policy": "german-support",
    "language": "de",
    "fnol_file": "fnol_alpenverbund.txt",
    "custom_patterns": [
      { "entity_type": "claim_ref",     "pattern": "AV-CL-[0-9]{6}\\b",                   "confidence": 1.0 },
      { "entity_type": "policy_number", "pattern": "AVP-[0-9]{4}-[0-9]{1,8}\\b",              "confidence": 1.0 },
      { "entity_type": "adjuster_ref",  "pattern": "AVR-[0-9]{4}-[0-9]{1,8}\\b",              "confidence": 0.95 },
      { "entity_type": "broker_agency", "pattern": "Maklerbüro ([A-Za-zÄÖÜäöüß]{2,40})\\b", "value_group": 1, "confidence": 0.95 }
    ]
  }
}

Each entry in a request's custom_patterns array takes these fields (from the example README):

FieldRequiredDescription
entity_typeyesThe type name the match becomes ({{custom:<entity_type>:...}}).
patternyesA regex; the whole match is tokenized unless value_group is set.
value_groupnoCapture-group index to tokenize instead of the whole match.
context_wordsnoWords that RAISE the match confidence when they appear nearby.
confidencenoDetection confidence, default 0.8.

FNOL fixtures

The two input reports the driver reads. All names, emails, IBANs, phone numbers, and identifiers are fictional. Musterversicherung Nord's report also carries a pasted German social-insurance number and an old US SSN, to exercise the safety-net custom pattern and the policy hard-redact.

fnol_rheinmark.txt:

Musterfirma: Erste Schadenmeldung (FNOL)

Mandant: Musterversicherung Nord AG
Eingang: 2026-07-06, telefonische Aufnahme durch die Hotline.

Versicherungsnehmerin: Erika Mustermann
Kontakt: erika.mustermann@kundenpost.example, Telefon 0170 5551234
Anschrift des Schadenorts: Lindenstrasse 12, 80331 München

Schaden: SCH-2026-000417
Versicherungsschein: VS-88-3320
Sparte: Wohngebäude, Leitungswasser
Gutachten: GUT-2026-19 (extern beauftragt)
Sachbearbeiter: SB-2026-07
Vermittelt durch Maklerbüro Musterbau.

Schilderung: In der Nacht auf den 5. Juli trat im Badezimmer Wasser aus einer
undichten Leitung aus. Betroffen sind Bodenbelag und Trockenbau. Die Kundin bittet
um schnelle Regulierung. Auszahlung bitte auf IBAN DE97 9900 0000 0123 4567 89.

Hinweis der Hotline: Zur Legitimation nannte die Kundin ungefragt ihre deutsche
Sozialversicherungsnummer 65 170839 K 003 sowie eine alte US-Nummer 123-45-6789 aus
ihrer Zeit im Ausland. Beides gehört nicht in die Akte und darf kein Modell erreichen.

fnol_alpenverbund.txt:

Musterfirma: Erste Schadenmeldung (FNOL)

Mandant: Musterversicherung Sued AG
Eingang: 2026-07-07, E-Mail-Eingang.

Versicherungsnehmer: Max Mustermann
Kontakt: max.mustermann@example.de, Telefon 0151 5559876
Anschrift des Schadenorts: Amselweg 8, 82041 Deisenhofen

Schadenvorgang: AV-CL-004417
Vertrag: AVP-2026-0118
Sparte: Kfz-Kasko, Hagelschaden
Regulierer: AVR-2026-33
Vermittelt durch Maklerbüro Beispiel.

Schilderung: Beim Hagel am 3. Juli wurden Dach und Motorhaube des Fahrzeugs
eingedellt. Der Kunde bittet um Begutachtung. Auszahlung bitte auf IBAN
DE97 9900 0000 0123 4567 89.

Driver (main.py)

The full driver, embedded verbatim. It reads the registry, runs the same FNOL without then with the carrier's custom_patterns, names the pattern shapes that produced each token, drafts a token-only German acknowledgment from safe metadata, restores it per audience and across every restore mode, then runs a second carrier and a malformed pattern to prove the fail-closed path. POST /v1/transform and POST /v1/rehydrate are the only API calls; GET /v1/capabilities reads ner_active.

"""Per-request custom patterns for a multi-carrier claims bureau (Python, raw HTTP).

THE PROBLEM. Musterfirma GmbH (a fictional Munich claims-processing bureau)
runs ONE OGuardAI server for several insurance carriers. A claims handler pastes an
inbound first-notice-of-loss (FNOL) report into an LLM to summarize it and draft a
German acknowledgment. Every report mixes two kinds of sensitive data:

  * Regulated PII the built-in detectors, NER, and the vertical policy already protect:
    the policyholder's name (gender-aware), email, phone, the damaged-property address,
    the payout IBAN, and the odd national ID a caller pasted by mistake.
  * Carrier-specific DOMAIN identifiers no built-in detector knows and that differ per
    carrier: the claim/Schaden number, the Versicherungsschein number, the adjuster and
    handler references, the broker agency. Musterversicherung Nord stamps `SCH-.../VS-...`; Musterversicherung Sued
    stamps `AV-CL-.../AVP-...`. Baking every tenant's regex into the server policy is
    impractical: the set is large, tenant-specific, and changes often.

THE FIX. Each request carries the tenant's identifiers as `custom_patterns`, read from a
per-carrier registry (`carriers.json`). They are validated on use, applied to that request
only, and never persisted. No policy edit, no redeploy, no server restart. A carrier's
scheme travels WITH its traffic.

THE CONTRACT (verified against the runtime, honest about precedence). A request-level
custom pattern is a SOFT detector. Highest-precedence first, the runtime resolves overlaps:
real hard-ID (SSN/IBAN/passport/...) > policy hard-custom > built-in PII (email/address/...)
> request custom_patterns > NER (person/company/location). So a custom pattern:
  * ADDS coverage for values nothing else claims (the carrier IDs), and
  * WINS the overlap against NER (it pins a broker name NER would mislabel), but
  * never SUPPRESSES a built-in PII span (it composes with them, it does not hide them).
`context_words` RAISE a match's confidence when they appear nearby; the hard context GATE
(`context_required`) and hard redaction (protection_level 1) are policy-level, not per-request.

Prerequisites:
    pip install requests
    # OGuardAI server with the NER detector (person/company/location + gender), e.g.:
    #   docker compose -f ../../deploy/docker/docker-compose.yml up -d

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 a label. The
                                                       # provider is never contacted here.
    python main.py
"""

from __future__ import annotations

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

import requests

# Accept whichever base-URL var is set so the example drops into any of the repo's conventions.
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", "")

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

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

# The model never sees raw PII; it reads OPENAI_API_KEY exactly like a real client would.
# To keep the example deterministic and offline, drafting fills a token-only German template;
# the real provider is never contacted. Setting the key only flips the printed label.
LLM_ENABLED = bool(os.environ.get("OPENAI_API_KEY"))

# Raw values from the Musterversicherung Nord FNOL that must never cross to the model. The built-ins own the
# structured identifiers, so a structured leak is a hard failure on every lane. The policyholder
# name and the broker agency depend on NER (the broker is the custom-over-NER case), so a name leak
# is a hard failure only when the runtime reports NER active, and a printed note otherwise.
STRUCTURED_SENTINELS = [
    "erika.mustermann@kundenpost.example",
    "0170 5551234",
    "DE97 9900 0000 0123 4567 89",
    "123-45-6789",
]
NER_SENTINELS = ["Erika Mustermann", "Musterbau"]


def post(path: str, body: dict) -> dict:
    resp = requests.post(f"{BASE}{path}", json=body, headers=HEADERS, timeout=60)
    # Fail closed: on any error we stop rather than fall back to processing raw text.
    # Surface the server's typed error code (GUARDAI_INVALID_INPUT, GUARDAI_POLICY_DENIED, ...).
    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 echo the raw upstream body: it can carry the request's PII. Trust only OGuardAI's
        # own structured error string; otherwise report the status code generically.
        error = detail.get("error")
        message = error if isinstance(error, str) else f"OGuardAI request failed (HTTP {resp.status_code})"
        raise GuardAIError(code, message, path)
    return resp.json()


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


def capabilities_ner_active() -> bool:
    """The one leak-exemption signal: /v1/capabilities.ner_active reports whether this runtime is
    configured to detect person names. If it cannot be read, treat NER as available so a name leak
    still fails closed: a protection example must never print an assurance it cannot justify."""
    try:
        resp = requests.get(f"{BASE}/v1/capabilities", headers=HEADERS, timeout=10)
    except requests.RequestException:
        return True
    if resp.status_code >= 400:
        return True
    try:
        body = resp.json()
    except ValueError:
        return True
    value = body.get("ner_active") if isinstance(body, dict) else None
    return value if isinstance(value, bool) else True


def check_boundary(text: str, ner_active: bool, *, where: str) -> list[str]:
    """Guard one string that crosses to the model. A structured identifier the built-ins own is a
    hard failure on every lane. A person or broker name is a hard failure when the runtime reports
    NER active, and a printed note otherwise, since running without NER is a valid deployment and the
    broker-agency custom-over-NER labeling degrades gracefully. Returns the name notes."""
    structured_leak = [value for value in STRUCTURED_SENTINELS if value in text]
    if structured_leak:
        raise SystemExit(f"FAIL: structured PII reached {where}: {structured_leak}")
    name_leak = [value for value in NER_SENTINELS if value in text]
    if not name_leak:
        return []
    if ner_active:
        raise SystemExit(f"FAIL: a name reached {where} while NER is active: {name_leak}")
    for value in name_leak:
        print(f"   NOTE: the name {value} reached {where} because this runtime runs without NER")
    return name_leak


def load_carriers() -> dict:
    return json.loads((HERE / "carriers.json").read_text(encoding="utf-8"))


def load_fnol(name: str) -> str:
    return (HERE / name).read_text(encoding="utf-8").strip()


def token_type(inner: str) -> str:
    """Type key from a token body: 'person:p_001:hash' -> 'person';
    'custom:claim_ref:x_001:hash' -> 'custom:claim_ref'."""
    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 in one safe text to the exact token string (hash included) the
    LLM must echo. Short forms like {{person:p_001}} do NOT resolve; the rehydrator needs
    the full string it emitted."""
    out: dict[str, str] = {}
    for inner in TOKEN_RE.findall(safe_text):
        out.setdefault(token_type(inner), "{{" + inner + "}}")
    return out


def custom_types(safe_text: str) -> list[str]:
    """The carrier's domain identifier types present in one safe text, e.g. ['claim_ref', ...]."""
    seen: list[str] = []
    for inner in TOKEN_RE.findall(safe_text):
        t = token_type(inner)
        if t.startswith("custom:") and t not in seen:
            seen.append(t.split(":", 1)[1])
    return seen


def rehydrate(draft: str, session_state: str, *, restore_mode: str | None = None,
              channel: str | None = None) -> str:
    body: dict = {"output": draft, "session_state": session_state}
    if restore_mode:
        body["restore_mode"] = restore_mode
    if channel:
        body["output_channel"] = channel
    return post("/v1/rehydrate", body)["restored_text"]


def transform(text: str, carrier: dict, *, with_custom: bool = True) -> dict:
    body: dict = {"input": text, "language": carrier["language"], "policy": carrier["policy"]}
    if with_custom:
        body["custom_patterns"] = carrier["custom_patterns"]
    return post("/v1/transform", body)


# The person restore already inserts the honorific (Herr/Frau). The gender metadata only has
# to pick the adjective ending that AGREES with it: "geehrter" for Herr, "geehrte" for Frau.
# That agreement is exactly what a gender-blind drafter gets wrong.
SALUTATION = {"male": "Sehr geehrter", "female": "Sehr geehrte"}


def draft_ack(safe_text: str, entity_context: list[dict]) -> str:
    """Token-only German acknowledgment. Every {{...}} is a placeholder the rehydrator fills;
    the drafter never touches a raw value. The national-ID token is deliberately never echoed
    into a customer-facing reply, whatever the policy would allow on restore."""
    tok = token_map(safe_text)
    person = tok.get("person", "")
    gender = next((e.get("gender") for e in entity_context if e.get("token") == person), "unknown")
    opener = SALUTATION.get(gender, "Guten Tag")

    claim = tok.get("custom:claim_ref")
    iban = tok.get("iban")
    handler = tok.get("custom:handler_ref") or tok.get("custom:adjuster_ref")

    lead = f"{opener} {person}" if person else "Sehr geehrte Damen und Herren"
    ref = f" zu Ihrer Schadenmeldung {claim}" if claim else ""
    pay = f" Eine etwaige Auszahlung erfolgt ausschliesslich auf {iban}." if iban else ""
    contact = f" Rueckfragen beantwortet Ihr Sachbearbeiter unter {handler}." if handler else ""
    return (f"{lead}, wir bestaetigen den Eingang{ref} und pruefen den Vorgang."
            f"{pay}{contact}")


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


# --- Section 1: the gap, then the per-request fix -------------------------------------------

def section_gap_and_fix(carrier: dict) -> dict:
    """Same FNOL, same policy, WITHOUT then WITH the carrier's custom_patterns. Without them
    the built-ins still protect the PII, but the carrier's domain identifiers leak raw. With
    them, on the very same request, those identifiers become typed tokens."""
    rule(f"1. THE GAP -> THE FIX  ({carrier['name']}, policy '{carrier['policy']}')")
    text = load_fnol(carrier["fnol_file"])

    without = transform(text, carrier, with_custom=False)
    with_c = transform(text, carrier, with_custom=True)

    leaked = [p["pattern"] for p in carrier["custom_patterns"]
              if re.search(p["pattern"], without["safe_text"])]
    gained = custom_types(with_c["safe_text"])

    print(f"   built-ins + NER alone: {without['stats']['entities_detected']} entities protected, "
          f"but {len(leaked)} carrier identifier pattern(s) still match RAW text:")
    print(f"     {without['safe_text']}\n")
    print(f"   + this request's custom_patterns: {with_c['stats']['entities_detected']} entities, "
          f"now protecting the carrier types {gained}:")
    print(f"     {with_c['safe_text']}\n")
    return with_c


# --- Section 2: pattern anatomy + fail-closed built-ins -------------------------------------

def section_pattern_anatomy(carrier: dict, transformed: dict) -> None:
    """Read the tokens the fix produced and name the three pattern shapes that made them,
    plus the two things the built-ins/policy did that a custom pattern is NOT for."""
    rule("2. PATTERN ANATOMY  (how each token was produced)")
    safe = transformed["safe_text"]
    by_type: dict[str, str] = {}
    for inner in TOKEN_RE.findall(safe):
        by_type.setdefault(token_type(inner), "{{" + inner + "}}")

    specs = {p["entity_type"]: p for p in carrier["custom_patterns"]}
    print("   carrier domain tokens (soft custom detectors, this request only):")
    for etype, spec in specs.items():
        token = by_type.get(f"custom:{etype}")
        if not token:
            continue
        shape = "value_group -> pins only the captured name, leaves the label untokenized" \
            if spec.get("value_group") is not None else "whole-match regex"
        print(f"     {token:44} <- /{spec['pattern']}/  ({shape}, confidence={spec['confidence']})")

    # broker_agency is the custom-wins-over-NER case: NER labels the surrounding phrase, the
    # value_group pins the actual agency name to the correct domain type.
    if by_type.get("company") and by_type.get("custom:broker_agency"):
        print("\n   custom over NER: the word 'Maklerbüro' is NER "
              f"{by_type['company']}, while the agency name is pinned to "
              f"{by_type['custom:broker_agency']} by the value_group pattern.")

    # A custom pattern never replaces the policy's own guarantees:
    if "[REDACTED:ssn]" in safe:
        print("   policy hard-redact: the pasted US SSN is dropped as [REDACTED:ssn] and never "
              "becomes a token; no custom pattern is needed or able to restore it.")
    if by_type.get("custom:de_social_id"):
        print("   safety net: the German social-insurance number, a national ID the built-ins do "
              f"not ship a matcher for in this spacing, is caught by a custom pattern "
              f"({by_type['custom:de_social_id']}) so it too leaves as a token, never raw.")
    print("   note: at request level context_words only BOOST confidence; the hard context gate "
          "and protection_level 1 are policy-level, not per-request.\n")


# --- Section 3: end-to-end, safe metadata -> gender-aware draft -> restore spectrum ---------

def section_end_to_end(carrier: dict, transformed: dict, ner_active: bool) -> list[str]:
    """The safe metadata (gender/formality) drives a correct German salutation; the LLM drafts
    on tokens; the same reply is restored per audience. The carrier claim ref restores in full
    for the handler and is masked/abstracted for downstream sinks, from ONE sealed session."""
    rule("3. END TO END  (safe metadata -> token-only draft -> restore spectrum)")
    safe, state = transformed["safe_text"], transformed["session_state"]
    ctx = transformed.get("entity_context", [])

    notes = check_boundary(safe, ner_active, where="the model")

    print("   metadata that left the boundary (drives grammar, never the raw value):")
    for ent in ctx:
        if ent.get("gender") not in (None, "unknown") or str(ent.get("token", "")).startswith("{{person"):
            print(f"     {ent['token'][:30]:32} gender={ent.get('gender'):8} "
                  f"formality={ent.get('formality'):8} lang={ent.get('language')}")

    model = "OpenAI (token-only)" if LLM_ENABLED else "offline template drafter"
    draft = draft_ack(safe, ctx)
    notes += check_boundary(draft, ner_active, where="the token-only draft")
    print(f"\n   drafting with: {model}")
    print(f"   token-only draft (what the model produced): {draft}\n")

    # One draft, three audiences, from the SAME sealed session:
    print("   per audience (one session, three channels):")
    print(f"     handler / customer (full) : {rehydrate(draft, state, restore_mode='full', channel='customer_email')}")
    print(f"     audit log (log_safe)      : {rehydrate(draft, state, channel='log_safe')}")
    print(f"     analytics (abstract)      : {rehydrate(draft, state, restore_mode='abstract')}\n")

    print("   the same reply under every restore mode (a mode only tightens what the policy "
          "allows, it never widens it):")
    for mode in ("full", "formatted", "masked", "partial", "abstract", "none"):
        print(f"     [{mode:9}] {rehydrate(draft, state, restore_mode=mode)}")
    print()
    return notes


# --- Section 4: multi-tenant on one server + fail-closed ------------------------------------

def section_multitenant(carriers: dict) -> None:
    """The whole point of per-request patterns: a second carrier with a completely different
    identifier scheme runs through the SAME server and the SAME policy with zero change, just
    a different custom_patterns set. Then a malformed pattern proves the fail-closed path."""
    rule("4. MULTI-TENANT, NO REDEPLOY  (a second carrier's scheme on the same server)")
    other = carriers["alpenverbund"]
    res = transform(load_fnol(other["fnol_file"]), other, with_custom=True)
    print(f"   {other['name']} (scheme {[p['entity_type'] for p in other['custom_patterns']]}):")
    print(f"     {res['safe_text']}")
    print(f"   protected carrier types: {custom_types(res['safe_text'])}  "
          f"(policy unchanged, server not restarted)\n")

    # Fail closed: a bad regex is rejected on use with a typed error; we never send raw text on.
    print("   fail-closed: a malformed custom pattern is rejected, the request does not proceed:")
    broken = {**other, "custom_patterns": [{"entity_type": "claim_ref", "pattern": "AV-CL-[0-9(", "confidence": 1.0}]}
    try:
        transform("Schaden AV-CL-004417.", broken, with_custom=True)
        raise SystemExit("fail-closed check failed: the runtime accepted an invalid regex")
    except GuardAIError as exc:
        print(f"     rejected with {exc.code} (raw text never left the client).\n")


def main() -> None:
    try:
        requests.get(f"{BASE}/v1/health", headers=HEADERS, timeout=10)
    except requests.RequestException as exc:
        raise SystemExit(f"Cannot reach OGuardAI at {BASE}: {exc}")
    ner_active = capabilities_ner_active()
    if not ner_active:
        print("WARNING: NER detector not active; person tokens, the gender-aware salutation, and "
              "the broker-agency (custom-over-NER) case will be missing. Start the full stack via "
              "docker compose.\n", file=sys.stderr)

    carriers = load_carriers()
    rheinmark = carriers["rheinmark"]

    try:
        transformed = section_gap_and_fix(rheinmark)
        section_pattern_anatomy(rheinmark, transformed)
        notes = section_end_to_end(rheinmark, transformed, ner_active)
        section_multitenant(carriers)
    except GuardAIError as exc:
        raise SystemExit(f"OGuardAI request failed: {exc}")

    if notes:
        leaked_names = sorted(set(notes))
        print("Done. Carrier identifiers and regulated PII stayed inside the runtime. The model saw "
              f"typed tokens for every structured value; the name(s) {leaked_names} reached it "
              "because this runtime runs without NER.")
    else:
        print("Done. Carrier identifiers and regulated PII both stayed inside the trusted zone; the model "
              "and every downstream sink saw typed tokens only, with no policy change per carrier.")


if __name__ == "__main__":
    main()

Expected output

The exact token hashes and IBAN mask length vary per run; the structure does not (from the example README):

1. THE GAP -> THE FIX  (Musterversicherung Nord AG, policy 'german-support')
   built-ins + NER alone: 6 entities protected, but 5 carrier identifier pattern(s) still match RAW text:
     ... Schaden SCH-2026-000417 ... Versicherungsschein VS-88-3320 ... Maklerbüro Musterbau ...
   + this request's custom_patterns: 11 entities, now protecting the carrier types
     ['claim_ref', 'policy_number', 'adjuster_ref', 'handler_ref', 'broker_agency', 'de_social_id']:
     ... Schaden {{custom:claim_ref:x_001:...}} ... Versicherungsschein {{custom:policy_number:x_002:...}} ...

2. PATTERN ANATOMY  (how each token was produced)
   carrier domain tokens (soft custom detectors, this request only):
     {{custom:claim_ref:x_001:...}}     <- /SCH-[0-9]{4}-[0-9]{1,8}\b/  (whole-match regex, confidence=1.0)
     {{custom:broker_agency:x_005:...}} <- /Maklerbüro ([A-Za-z...]{2,40})\b/  (value_group -> pins only the captured name ...)
   custom over NER: the word 'Maklerbüro' is NER {{company:...}}, while the agency name is pinned to {{custom:broker_agency:...}} ...
   policy hard-redact: the pasted US SSN is dropped as [REDACTED:ssn] and never becomes a token ...
   safety net: the German social-insurance number ... is caught by a custom pattern ({{custom:de_social_id:...}}) ...

3. END TO END  (safe metadata -> token-only draft -> restore spectrum)
   metadata-driven salutation (gender=female): Sehr geehrte ...
   per audience (one session, three channels):
     handler / customer (full) : Sehr geehrte Frau Erika Mustermann, wir bestaetigen den Eingang zu Ihrer Schadenmeldung SCH-2026-000417 ...
     audit log (log_safe)      : Sehr geehrte [REDACTED], wir bestaetigen den Eingang zu Ihrer Schadenmeldung [REDACTED] ...
     analytics (abstract)      : Sehr geehrte (Versicherungsnehmerin), ...
   the same reply under every restore mode ...
     [full     ] ... [formatted] ... [masked   ] ... [partial  ] ... [abstract ] ... [none     ] ...

4. MULTI-TENANT, NO REDEPLOY  (a second carrier's scheme on the same server)
   Musterversicherung Sued AG (scheme ['claim_ref', 'policy_number', 'adjuster_ref', 'broker_agency']):
     Schadenvorgang {{custom:claim_ref:x_001:...}} ... Vertrag {{custom:policy_number:x_002:...}} ...
   fail-closed: a malformed custom pattern is rejected, the request does not proceed:
     rejected with GUARDAI_... (raw text never left the client).

Without them the built-ins still protect the regulated PII; with them, on the very same request, the carrier's domain identifiers become typed tokens too, and a second carrier with a different scheme runs through the same server and the same policy with no change.

Advanced: contribute a built-in detector (requires a code change)

You only edit Rust to promote a detector into the shipped built-in floor (so every deployment gets it without configuring custom_patterns), to give a type its own token id prefix, or to hand-write a formatted/abstract restore beyond the template floor. This is a contributor path, not a deployment path: it edits the proprietary runtime source, so the steps (the all_patterns() entry, the EntityType variant, the restore match arm, and the pattern test) live in CONTRIBUTING.md under "Extending the Runtime", alongside the build and test gate. See also Contributing.

Limitations

  • Regex, NER, or an external detector. Inside a policy a custom type is detected by a regex pattern or by an NER label, so a format-free type needs the sidecar via ner_labels. A deployment that cannot express the type either way can instead register an external detection service under detector.webhook in oguardai.yaml and have it return the spans; see the plugin development guide.
  • Built-in wins an overlap. A custom pattern adds detection; where its match overlaps a built-in detector (for example a money-shaped value), the built-in type wins. Custom patterns are additive and never weaken a built-in.
  • Shared token prefix. Policy-defined custom types share the {{custom:<type>:id}} form and the x_ id prefix; a distinct prefix needs the enum change in the advanced section.