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:
| Refused | Write 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 windowDiagnose 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_strategiesblock. It applies only when the entity resolves atpartialfor 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 viarestore_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 typerequired_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) insteadIt 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: rejectreturns403 GUARDAI_POLICY_DENIEDbefore 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_typeis declared like acustom_patternstype; 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
| Capability | How (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 / transform | Automatic |
| Rehydrate full / partial / masked / none | Automatic (restore_mode) |
| Rehydrate partial last-N / bucket | restore_strategies |
| Rehydrate formatted / abstract label | restore-template config |
| Policy rules / conditions / channels | Automatic (entity_type match) |
| Output guard floor | Automatic (untrusted channels masked) |
| Revocation | Automatic |
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 stampsAV-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.txtStart 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.0Configuration is read from the environment:
GUARDAI_BASE_URL(orGUARDAI_URL/GUARDAI_BASE), defaulthttp://localhost:3000.GUARDAI_API_KEY, sent asX-API-Keywhen 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.pyPer-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):
| Field | Required | Description |
|---|---|---|
entity_type | yes | The type name the match becomes ({{custom:<entity_type>:...}}). |
pattern | yes | A regex; the whole match is tokenized unless value_group is set. |
value_group | no | Capture-group index to tokenize instead of the whole match. |
context_words | no | Words that RAISE the match confidence when they appear nearby. |
confidence | no | Detection 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
patternor by an NER label, so a format-free type needs the sidecar vianer_labels. A deployment that cannot express the type either way can instead register an external detection service underdetector.webhookinoguardai.yamland 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 thex_id prefix; a distinct prefix needs the enum change in the advanced section.