Policy Authoring
Write declarative YAML policies that control how OGuardAI handles each entity type
Policies are declarative YAML documents that define how OGuardAI handles each entity type. They control detection, transformation, and restoration behavior.
Policy Evaluation Flow
Policy YAML Structure
name: "my-policy"
version: "1.0.0"
description: "What this policy does and when to use it"
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "full"
on_redact: "continue"
metadata_policy:
expose_gender: true
expose_formality: true
expose_language: true
expose_role: trueTop-Level Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Unique policy identifier (referenced in API requests) |
version | Yes | Semantic version for change tracking |
description | No | Human-readable purpose description |
rules | Yes | List of entity-type rules |
defaults | Yes | Fallback values for entity types without explicit rules |
metadata_policy | Yes | Controls which metadata fields are exposed to the LLM (each flag defaults to false) |
custom_patterns | No | Policy-scoped regex detectors (additive; never suppress a built-in) |
detection | No | Detection controls: require NER for some entity types (fail closed), set a confidence floor |
Per-Policy Detection (custom_patterns, detection)
A policy can add its own detectors and tighten detection, for any tenant, domain, or locale, without code. These controls are additive and fail closed: a policy can only add detection or require more, never silently weaken it.
custom_patterns:
- entity_type: "employee_id" # a custom:<type> entity; use a built-in name to extend that type
pattern: '\bEMP-\d{6}\b'
confidence: 0.9
context_words: ["employee", "staff"]
detection:
required_for: ["person", "company", "location"] # fail closed if NER did not run for these
min_confidence: 0.6 # drop detections below this confidencerequired_for makes the policy refuse a request rather than fall back to weaker regex-only
detection when the NER detector did not actually run for an NER-dependent type, so "the model
never sees a real name" holds even on a transient NER outage. min_confidence raises the
detection floor uniformly.
min_confidence is a precision knob, not a calibrated probability. Confidence is per-backend
and the scales are not comparable: built-in regex assigns fixed pattern confidences, GLiNER returns
raw model scores, and spaCy stamps a default. A single min_confidence therefore means different
things per backend, so tune it empirically against your own data rather than treating it as a
probability. To trade recall against precision at the source, adjust the sidecar's
GLINER_CONFIDENCE_THRESHOLD. min_confidence only ever tightens: it can drop low-confidence
non-required spans, never admit one a stricter floor would reject.
This is how a deployment supports its own domain with no code change. The shipped policies show it:
the opt-in healthcare-hipaa template (under policies/templates/, requires NER) defines mrn/npi/dea, financial defines bank_account/routing_number,
hr defines employee_id (full only for hr_manager), government defines
clearance_id/facility_code, and legal-privilege defines matter_id.
Every entity_type a policy references, in a rule, a channel override, a restore_strategies
key, or detection.required_for, must be a built-in type or a custom type the same policy
declares in custom_patterns. An unknown label aborts startup instead of silently becoming a rule
that never matches, so a mistyped type (e.g. sssn for ssn, or persn in required_for) cannot
quietly drop protection. To target a custom entity detected by a deployment-level detector rather
than this policy, write it as custom:<name> to confirm intent and opt out of the typo check.
Entity Rules
Each rule defines how one entity type is handled:
rules:
- entity_type: "person" # Which entity type this rule applies to
protection_level: 2 # 1 = hard mask/redact, 2 = reversible tokenization, 3 = semantic abstraction
action: "tokenize" # What action to take (redact | abstract | tokenize)
conditions: [] # Optional conditional overridesProtection Levels
| Level | Applies To | Description |
|---|---|---|
| 1 | passport, vat_id, iban, credit_card, health_id, ssn | Hard masking or redaction. Raw values never stored in reversible form. |
| 2 | person, email, phone, company, customer_id, order, address, location, date_of_birth, ip, url, custom | Reversible tokenization. Values can be fully restored during rehydration. |
| 3 | Metadata-only attributes | Semantic abstraction. No raw value stored. |
protection_level is a declarative tier annotation: it records intent and is surfaced in the
policy-evaluation trace and policy diffs, but it does not enforce on its own. The effective handling
of an entity is driven by action and restore_mode; keep protection_level consistent with them
(e.g. a level-1 entity should use redact or a non-restorable restore_mode, not tokenize with
restore_mode: full).
Policy Actions
The action field in each rule controls what happens to the entity during transformation. Valid values come from the PolicyAction enum:
| Action | Behavior |
|---|---|
redact | Value is replaced with the sentinel [REDACTED:type] (e.g. [REDACTED:ssn]), purged from the session, and non-restorable. By default the request still returns 200; set on_redact: reject to fail the request with 403 instead. |
tokenize | Replaced with a semantic token {{type:id:cap}} (reversible via rehydration). To mask on rehydration, set the rule's restore_mode: masked. |
abstract | Replaced with category label (e.g., [IBAN]) |
To send a specific value to the model unchanged, list it in the policy whitelist. The authorable actions are exactly redact, tokenize, and abstract: a policy that writes action: allow is rejected at load. allow exists only as an internal backstop, and the protection lattice clamps it to tokenize, so whitelist is the only pass-through path.
Redaction Disposition (on_redact)
redact removes the value but, by default, the request still succeeds with the
value redacted in place. The on_redact field controls whether a redaction also
fails the whole request:
| Value | Behavior |
|---|---|
continue (default) | Redact the value to [REDACTED:type], purge it, return 200. |
reject | Return 403 with error code GUARDAI_POLICY_DENIED. |
Set it as a policy-level default under defaults, or per rule. A per-rule value
overrides the policy default. It only affects redact actions.
rules:
- entity_type: "ssn"
protection_level: 1
action: "redact"
on_redact: "reject" # this entity hard-fails the request
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "full"
on_redact: "continue" # other redactions redact-and-continueRestore Modes
The restore_mode field (set in defaults or in channel overrides) controls how tokenized entities are restored during rehydration:
| Restore Mode | Behavior |
|---|---|
full | Complete original value restored |
partial | Deterministic subset (e.g., J. Schneider) |
masked | Character masking preserving length |
formatted | Original + contextual formatting (e.g., "Frau Julia Schneider") |
abstract | Semantic description (e.g., "(female customer)") |
none | Value removed, shows [REDACTED] |
Protect without restoring
Rehydration is optional. If your deployment should mask or tokenize but never bring the original value back, pick the pattern that fits:
- Tokenize only. Call
POST /v1/transform, send the tokenizedsafe_textto the model, and never call/v1/rehydrate. The{{type:id:cap}}tokens stay in the output and nothing is restored. - Irreversible redaction. Set
action: redacton the rule. The value becomes[REDACTED:type], is purged from the session, and never reaches the model even as a token. Addon_redact: rejectto fail the request instead. This is how cardholder data satisfies PCI-DSS Requirement 3. - Tokenize but never reveal. Keep
action: tokenizefor model context but setrestore_mode: none,masked, orabstract, so a rehydrate call can never return the raw value. - Detect only. Call
POST /v1/detectto find entities and spans without transforming anything.
action decides reversibility at transform time; restore_mode bounds what a rehydrate may reveal.
Language Coverage
formatted and abstract restoration are localized. The runtime ships built-in person titles and company/address/location/person abstract labels for 30 languages, and a deployment can override or add any language through restore_templates in oguardai.yaml (or GUARDAI_RESTORE_TEMPLATES for the proxy); the configured entries merge over the built-ins at startup. A language with no built-in or configured label falls back to a safe generic label; it is never the raw value and never an error. Coverage is a convenience floor, not a hard limit.
Custom Restore Strategies
When the six built-in modes are not enough (PCI card last-4, MRN last-3, an account number's last digits, a salary band instead of an exact figure), a restore_strategies block customizes the partial algorithm per entity type, with no code change. It is keyed by entity type, including a custom:<type> entity from custom_patterns. See the Extending Entities guide for how custom types are defined.
There are five strategy kinds:
| Kind | Parameters | What it reveals |
|---|---|---|
reveal_last_n | n (1 to 64), mask_char (optional, default "*") | The last n characters, masking the rest. Whitespace grouping is preserved. |
reveal_first_n | n (1 to 64), mask_char (optional) | The first n characters, masking the rest. For a fixed prefix such as an IBAN country code or a postal-code prefix. |
reveal_range | start (0 to 64), len (1 to 64), mask_char (optional) | A len-character window starting at 0-based character start, masking the rest. For an interior span such as a phone area code. |
mask | none | Nothing. Every character is masked. This is the fail-closed partial. |
bucket | ranges (a non-empty list of { max?, label }) | A coarse range label only (e.g. a salary band). The raw number is never shown. |
restore_strategies:
credit_card:
kind: reveal_last_n # show only the last n characters, mask the rest
n: 4
mask_char: "*" # optional, default "*"
mrn:
kind: reveal_last_n
n: 3
iban:
kind: reveal_first_n # show only the leading country code, mask the rest
n: 2
phone:
kind: reveal_range # reveal only characters start..start+len
start: 4
len: 3
account_token: # masks fully even at partial
kind: mask
salary:
kind: bucket # numeric range generalization, not the exact value
ranges:
- { max: 50000, label: "<50k" }
- { max: 100000, label: "50k-100k" }
- { label: "100k+" } # omit max on the last range to catch everything aboveA bucket maps a numeric value into the first range whose max it does not exceed; the final range may omit max to catch everything above. A value that is not a single number, or that matches no range, masks fully rather than revealing anything.
The mrn, account_token, and salary keys above are custom types: the same policy must declare them in custom_patterns (or write them as custom:<name> to target a deployment-level detector), otherwise the load aborts with an unknown entity-type error.
Channel-ceiling gating
A strategy applies only when the channel-resolved restore mode for that entity is exactly partial. The rehydrate engine clamps the restore mode to the channel ceiling first, so a channel whose resolved mode is masked, abstract, or none never reaches partial, and the strategy does not run there. A strategy can therefore add a bounded reveal within partial, never beyond what the channel already permits.
If the strategy cannot produce a bounded result it masks the whole value, never the raw value: reveal_last_n and reveal_first_n mask fully when the value has n or fewer characters (revealing n would disclose all of it), reveal_range masks fully when the window is anchored at the start and reaches the end (any start above 0 already masks the prefix, so it cannot disclose everything), and bucket masks an unparseable or unmatched value. Parameters are validated at policy load, so an invalid value (for example n or len outside 1 to 64, or a bucket with an empty ranges) aborts startup. The line is the same as everywhere in the policy: config may only add or tighten, and invalid config fails closed.
With restore_mode: partial, 4111 1111 1111 4821 restores as **** **** **** 4821 under the credit_card strategy above.
Fail-closed partial defaults
When an entity restores at partial and no restore_strategies entry covers it, the engine applies a built-in default:
- High-sensitivity identifiers (
iban,ssn,credit_card,passport,health_id) and everycustom:<type>entity reveal nothing inpartial: they mask fully unless arestore_strategiesentry opts that type into a bounded reveal. This is why an unconfigured custom identifier is safe atpartialout of the box. person,email,phone,company, andaddresshave built-in partial shapes (for exampleJ. Schneider). Person partials mask instead of revealingF. Surnamefor CJK-script and family-first names (zh, ja, ko, vi, hu), where that initial would be a misidentified name part.- Any other entity type with no built-in shape and no strategy masks fully. The default is always to reveal less, never more.
Strategies follow the same inheritance rule as entity and channel rules: a child policy overrides the parent's strategy for an entity type it lists, but omitting the type does not remove an inherited strategy (re-declare it to change it).
Conditional Rules
Rules support conditions for context-dependent behavior. Conditions are evaluated
against detection confidence and the context object in the API request.
Three condition types are fully enforced at runtime:
confidence: numeric threshold conditions on detection confidence scorescaller_role: role-based conditions evaluated against the caller's authenticated rolecaller_purpose: purpose-based conditions evaluated against the caller's authenticated purpose (e.g., "support", "analytics", "compliance")
Production: bind caller_role to API key config or JWT claims. In non-dev auth modes, request-supplied context.caller_role is ignored unless the auth credential has a bound role. This prevents callers from self-asserting privileged roles.
When a condition includes caller_role and no role is available (neither auth-bound nor dev-mode request), the condition does not match and the base rule action applies.
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions:
- field: "caller_role"
operator: "eq"
value: "support_agent"
override_restore_mode: "partial"
- field: "caller_role"
operator: "in"
value: ["admin", "supervisor"]
override_restore_mode: "full"
- field: "caller_role"
operator: "neq"
value: "guest"
override_restore_mode: "tokenize"Production: bind caller_role to your API key in the server config:
auth:
api_keys:
- key: "${GUARDAI_API_KEY}"
identity: "admin-service"
caller_role: "admin"
scopes: ["transform", "rehydrate"]Dev/testing only: pass caller_role in the transform request context:
curl -X POST http://localhost:3000/v1/transform \
-H "Content-Type: application/json" \
-d '{
"input": "Contact Julia Schneider at julia@example.com",
"policy": "enterprise",
"context": {
"caller_role": "admin",
"caller_purpose": "customer escalation review"
}
}'Condition Operators
| Operator | Description | Example | Status |
|---|---|---|---|
eq | Equals | field: "caller_role", value: "admin" | Enforced |
neq | Not equals | field: "caller_role", value: "guest" | Enforced |
in | In list | field: "caller_role", value: ["admin", "support"] | Enforced |
gte | Greater than or equal | field: "confidence", value: 0.85 | Enforced |
lte | Less than or equal | field: "confidence", value: 0.5 | Enforced |
gt | Greater than | field: "confidence", value: 0.7 | Enforced |
lt | Less than | field: "confidence", value: 0.3 | Enforced |
not_in | Not in list | field: "caller_role", value: ["guest", "public"] | Planned |
exists | Field is present | field: "vip_flag" | Planned |
How Role-Based Conditions Work
- A policy rule defines conditions with
field: "caller_role". - The caller's role is determined from auth credentials (
caller_rolein API key config or JWT claims). In dev mode,context.caller_rolein the request is also accepted. - The policy engine evaluates each condition against the authenticated role.
- If a condition matches, its
override_restore_modeoverrides the restore mode (not the action, entities are still tokenized). - If no role is available, role-based conditions are skipped and the base action applies.
Re-authorized at rehydrate. A caller-gated override_restore_mode is sealed into the session as provenance, not as a fixed mode, and re-resolved at rehydrate time against the authenticated rehydrate caller (API key or JWT, never a request-supplied field). Holding a session_state produced under a privileged role therefore does not grant elevated restoration: a rehydrate caller who lacks the role degrades to the policy floor (the base restore mode without any caller elevation), and a restrictive override (for example caller_role neq admin mapping to none) still tightens for an unauthorized or roleless caller. The output channel ceiling is then applied on top, so a safe sink (log_safe, export) clamps the result regardless of role.
This enables enterprise policies where different roles see different levels of protection for the same entity types, enforced at the moment of restoration rather than only at transform time.
Channel Overrides
Output channels control how entities are restored for different audiences:
channel_rules:
customer_email:
person: "formatted" # "Frau Julia Schneider"
email: "none" # [REDACTED]
customer_id: "full" # 948221
order: "full" # ORD-2026-4892
internal_summary:
person: "full" # Julia Schneider
email: "full" # julia@example.com
customer_id: "full" # 948221
order: "full" # ORD-2026-4892
export:
person: "abstract" # (female customer)
email: "none" # [REDACTED]
customer_id: "abstract" # (customer ID)
order: "abstract" # (order reference)
log_safe:
person: "masked" # J**** ********r (first and last char kept)
email: "masked" # j*******************e
customer_id: "masked" # 9****1
order: "partial" # ORD-****-****The built-in channels (user_output, customer_email, internal_summary, tool_payload,
export, log_safe) carry sensible default restore modes. You can also define your own
channels for any workflow: any name matching [a-z0-9_-]+ becomes a custom channel.
channel_rules:
regulator_export: # bank: deliver to a regulator
person: "full"
iban: "masked"
warehouse_label: # ecommerce: shipping label
person: "full"
address: "full"
email: "none"A custom channel is fail-closed: with no rule it restores nothing, so a typo in the request's
output_channel can never over-expose. Define the rule to opt into the restoration you want.
Metadata Policy
Controls which metadata fields are included in the entity_context sent to the LLM:
metadata_policy:
expose_gender: true # Needed for gendered salutations (Herr/Frau)
expose_formality: true # Needed for formal/informal register
expose_language: true # Needed for language-correct output
expose_role: true # Emits the policy-defined entity role in entity_contextexpose_gender, expose_formality, expose_language, and expose_role each gate one metadata
field on the entity_context: when false, that field is withheld. expose_role surfaces the
policy-defined entity role produced by the roles: block (see the
Extensibility guide). With no roles: declared, no role is produced,
so the field is simply absent; declaring a role taxonomy and setting expose_role: true makes it
appear, gated exactly like the other three.
For the rest of the per-vertical dynamic surface not covered here, the roles: taxonomy, the RAG
classification lattice, egress destination_permissions, detector vocab overlays, and language
packs, see the Extensibility guide.
Example: Healthcare Policy
name: "healthcare"
version: "1.0.0"
description: "HIPAA-aligned healthcare policy: blocks PHI identifiers, tokenizes names, abstracts addresses"
rules:
- entity_type: "health_id"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "date_of_birth"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "iban"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "credit_card"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "passport"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "phone"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "address"
protection_level: 3
action: "abstract"
conditions: []
- entity_type: "location"
protection_level: 2
action: "abstract"
conditions: []
- entity_type: "company"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "customer_id"
protection_level: 2
action: "tokenize"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "partial"
detection:
required_for:
- "person"
metadata_policy:
expose_gender: false
expose_formality: false
expose_language: true
expose_role: falseKey decisions:
- Health IDs, SSNs, dates of birth, IBANs, credit cards, and passports are all blocked (HIPAA requirement)
- Person names, emails, and phones are tokenized (reversible during rehydration)
- Addresses are abstracted at protection level 3 (no raw value stored)
- Default restore mode is
partial: only subsets of values are restored - Person detection requires NER (
detection.required_for: [person]), so the policy fails closed rather than falling back to regex-only if the NER detector is unavailable
Example: Financial Policy
name: "financial"
version: "1.0.0"
description: "Banking and financial services policy: blocks all financial identifiers, tokenizes names, no raw values in logs"
rules:
- entity_type: "iban"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "credit_card"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "passport"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "health_id"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "company"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "phone"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "address"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "location"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "customer_id"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "order"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "date_of_birth"
protection_level: 1
action: "tokenize"
restore_mode: "masked"
conditions: []
- entity_type: "ip"
protection_level: 2
action: "tokenize"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "masked"
metadata_policy:
expose_gender: false
expose_formality: false
expose_language: true
expose_role: trueKey decisions:
- IBAN, credit card, SSN, passport, and health ID are always redacted (never reach the LLM)
- Person names, companies, emails, and other identifiers are tokenized
- Date of birth uses
tokenizewithrestore_mode: masked: reversible per policy, shown masked at rehydration, not destroyed - Default restore mode is
masked: character masking preserving length
Policy File Location
Policies are stored as YAML files in the configured policy directory. The shipped set below is representative, not exhaustive; read your deployment's policy directory for the authoritative list:
policies/
customer-support-de/policy.yaml
customer-support-en/policy.yaml
default/policy.yaml
enterprise/policy.yaml
examples/
financial/policy.yaml
gdpr-strict/policy.yaml
german-support/policy.yaml
government/policy.yaml
healthcare/policy.yaml
hr/policy.yaml
legal/policy.yaml
legal-privilege/policy.yaml
minimal/policy.yaml
strict-pii/policy.yaml
templates/
healthcare-hipaa/policy.yaml # opt-in, NER-required templatePolicy changes are picked up without a server restart: on demand via POST /v1/admin/policy/reload (requires the admin scope; a reload that would load zero policies or fails validation is rejected and the running policy is kept), or automatically by enabling the policy-directory watcher with policy.watch: true. Use POST /v1/admin/policy/validate for a dry-run check before reloading.