OGuardAI
Guides

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: true

Top-Level Fields

FieldRequiredDescription
nameYesUnique policy identifier (referenced in API requests)
versionYesSemantic version for change tracking
descriptionNoHuman-readable purpose description
rulesYesList of entity-type rules
defaultsYesFallback values for entity types without explicit rules
metadata_policyYesControls which metadata fields are exposed to the LLM (each flag defaults to false)
custom_patternsNoPolicy-scoped regex detectors (additive; never suppress a built-in)
detectionNoDetection 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
  ner_min_confidence: 0.7                          # NER-only precision floor; must be >= 0.65

A custom_patterns regex must have a bounded match length and must not match arbitrary text. \bEMP-\d{6}\b loads; \bEMP-\d+\b is refused for the first rule (bound it as \bEMP-\d{1,10}\b), and a bare \d{1,10} with no literal around it is refused for the second. The bound is a trust-boundary requirement rather than a tuning knob: when a response is streamed, the PII guard can withhold only a bounded number of bytes (8192) while an entity assembles, so a match that can exceed that would put the leading bytes of a real value on the wire before anything could mask them. The full rules, including the whitespace exception and the budget a context_required pattern spends, are in Extending Entities.

required_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. ner_min_confidence raises the precision floor for NER spans.

Renamed: detection.min_confidence is now detection.ner_min_confidence. The old key applied to every span, including built-in regex hits, so a policy that raised it could drop a hard identifier the regex detector had matched exactly. The floor now applies to NER spans only, where a confidence score is a model output rather than a fixed per-pattern constant. It is validated to [0.65, 1.0]. The rename is a hard load error, not a silent reinterpretation: a policy still using min_confidence is rejected at load with an unknown-field error rather than being quietly mapped onto a key with different semantics. Rename the key and check the value against the new range.

ner_min_confidence is validated to [0.65, 1.0] and may only tighten: 0.65 is the built-in NER floor and a policy cannot weaken it. A value outside that range is rejected at load, not clamped. A required_for type is exempt from it entirely, so tightening one control can never silently drop a type another control demands.

It never filters a fixed-confidence regex detection, wherever that detection was produced. This is decided per span from the producer the detector declared, not from which component the span arrived through: the NER sidecar is not only a model, it also runs its own regex patterns (for example German street suffixes the built-in patterns do not carry) and returns both kinds in one response. Those pattern spans carry a constant confidence their author chose, so a floor cannot read it as a quality signal, and applying one would delete the only detection of a real value. A sidecar that declares no producer for a span is treated the same way: the floor is skipped rather than guessed at, because the cost of skipping it is at worst masking something that did not need masking, while the cost of guessing wrong is leaving a real value raw.

The floor is a precision knob, not a calibrated probability. Confidence is per-backend and the scales are not comparable: GLiNER returns raw model scores and spaCy stamps a default, so one value means different things per backend. 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. To raise the floor for the whole deployment rather than one policy, set GUARDAI_NER_MIN_CONFIDENCE; the effective floor is always the strictest of the three.

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 overrides

Protection Levels

LevelApplies ToDescription
1passport, vat_id, iban, credit_card, health_id, ssnHard masking or redaction. Raw values never stored in reversible form.
2person, email, phone, company, customer_id, order, address, location, date_of_birth, ip, url, customReversible tokenization. Values can be fully restored during rehydration.
3Metadata-only attributesSemantic 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:

ActionBehavior
redactValue 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.
tokenizeReplaced with a semantic token {{type:id:cap}} (reversible via rehydration). To mask on rehydration, set the rule's restore_mode: masked.
abstractReplaced 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:

ValueBehavior
continue (default)Redact the value to [REDACTED:type], purge it, return 200.
rejectReturn 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-continue

Restore Modes

The restore_mode field (set in defaults or in channel overrides) controls how tokenized entities are restored during rehydration:

Restore ModeBehavior
fullComplete original value restored
partialDeterministic subset (e.g., E. Mustermann)
maskedCharacter masking preserving length
formattedOriginal + contextual formatting (e.g., "Frau Erika Mustermann")
abstractSemantic description (e.g., "(female customer)")
noneValue 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 tokenized safe_text to the model, and never call /v1/rehydrate. The {{type:id:cap}} tokens stay in the output and nothing is restored.
  • Irreversible redaction. Set action: redact on the rule. The value becomes [REDACTED:type], is purged from the session, and never reaches the model even as a token. Add on_redact: reject to fail the request instead. This supports PCI-DSS 3.4 (render PAN unreadable); it does not satisfy all of Requirement 3 on its own.
  • Tokenize but never reveal. Keep action: tokenize for model context but set restore_mode: none, masked, or abstract, so a rehydrate call can never return the raw value.
  • Detect only. Call POST /v1/detect to 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:

KindParametersWhat it reveals
reveal_last_nn (1 to 64), mask_char (optional, default "*")The last n characters, masking the rest. Whitespace grouping is preserved.
reveal_first_nn (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_rangestart (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.
masknoneNothing. Every character is masked. This is the fail-closed partial.
bucketranges (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 above

A 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 every custom:<type> entity reveal nothing in partial: they mask fully unless a restore_strategies entry opts that type into a bounded reveal. This is why an unconfigured custom identifier is safe at partial out of the box.
  • person, email, phone, company, and address have built-in partial shapes (for example E. Mustermann). Person partials mask instead of revealing F. Surname for 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 scores
  • caller_role: role-based conditions evaluated against the caller's authenticated role
  • caller_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:
      # Matching overrides are applied in list order and the LAST match wins, so
      # list the broad rule first and the narrow one last.
      - field: "caller_role"
        operator: "in"
        value: ["admin", "supervisor"]
        override_restore_mode: "full"

      - field: "caller_role"
        operator: "eq"
        value: "support_agent"
        override_restore_mode: "partial"

      - field: "caller_role"
        operator: "eq"
        value: "guest"
        override_restore_mode: "none"

override_restore_mode takes a restore mode (full, partial, masked, formatted, abstract, none), never an action. An action name there is rejected at load with an unknown-restore-mode error rather than being reinterpreted.

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 Erika Mustermann at erika.mustermann@example.com",
    "policy": "enterprise",
    "context": {
      "caller_role": "admin",
      "caller_purpose": "customer escalation review"
    }
  }'

Condition Operators

OperatorDescriptionExampleStatus
eqEqualsfield: "caller_role", value: "admin"Enforced
neqNot equalsfield: "caller_role", value: "guest"Enforced
inIn listfield: "caller_role", value: ["admin", "support"]Enforced
gteGreater than or equalfield: "confidence", value: 0.85Enforced
lteLess than or equalfield: "confidence", value: 0.5Enforced
gtGreater thanfield: "confidence", value: 0.7Enforced
ltLess thanfield: "confidence", value: 0.3Enforced
not_inNot in listfield: "caller_role", value: ["guest", "public"]Planned
existsField is presentfield: "vip_flag"Planned

How Role-Based Conditions Work

  1. A policy rule defines conditions with field: "caller_role".
  2. The caller's role is determined from auth credentials (caller_role in API key config or JWT claims). In dev mode, context.caller_role in the request is also accepted.
  3. The policy engine evaluates each condition against the authenticated role.
  4. If a condition matches, its override_restore_mode overrides the restore mode (not the action, entities are still tokenized).
  5. 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. The examples below assume a base restore_mode: "full" on person, email, customer_id, and order, because a channel rule can only tighten (see below).

channel_rules:
  customer_email:
    person: "formatted"      # "Frau Erika Mustermann"
    email: "none"            # [REDACTED]
    customer_id: "full"      # 948221
    order: "full"            # ORD-2026-4892

  internal_summary:
    person: "full"           # Erika Mustermann
    email: "full"            # erika.mustermann@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)

A channel rule can only tighten

At rehydrate the runtime resolves the entity's base restore mode (the rule's restore_mode, or the policy defaults), then applies the channel rule and keeps whichever of the two exposes less. A channel rule therefore narrows restoration and never widens it: listing iban: "full" under internal_summary for an entity whose base is masked leaves it masked, and an entity whose base is none stays [REDACTED] on every channel. To let one channel restore an entity in full, stamp the base full on the rule and tighten it back down on the other channels.

The two safe sinks have a hard ceiling

log_safe and export are clamped before the channel rule is even consulted, so naming one of them can never reveal raw values just because the policy default was permissive:

ChannelCeilingEffect
log_safenoneEvery entity restores to [REDACTED], whatever the base or the channel rule says
exportmaskedAn entity restores masked at most; a channel rule may tighten further (abstract, none)

A log_safe entry under channel_rules is therefore never observable in the output. Keep the channel out of the policy, or leave the entries only as documentation of intent.

Six channel names are built in: user_output, customer_email, internal_summary, tool_payload, export, log_safe. They carry no restore modes of their own. A built-in channel with no entry under channel_rules applies the entity's base restore mode unchanged (after the safe-sink ceiling above), so the modes come from what you write plus that base, not from the channel name. 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. Defining the rule is necessary but not sufficient: a custom channel is a machine sink outside the trust boundary until the deployment also names it in GUARDAI_TRUSTED_OUTPUT_CHANNELS, and until then the runtime clamps it to none whatever the rule says (see Extensibility). The rule still only tightens: person: "full" on regulator_export restores in full only if the person rule already stamps a base of full.

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_context

expose_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

The shipped healthcare policy, abridged to its entity rules. The full file also redacts age and declares HIPAA Safe Harbor custom types under custom_patterns (age for 90 and above, mrn, account_number, certificate_number, service_date). Read policies/healthcare/policy.yaml for the authoritative version.

name: "healthcare"
version: "1.0.0"
description: "HIPAA-aligned healthcare policy: redacts 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: false

Key decisions:

  • Health IDs, SSNs, dates of birth, IBANs, credit cards, and passports are redacted to [REDACTED:type], purged from the session, and never restorable
  • 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

The shipped financial policy, abridged to its entity rules. The full file also declares bank_account and routing_number as label-anchored custom_patterns (tokenized, restored masked), sets on_redact: continue under defaults, and lists person and company under detection.required_for. Read policies/financial/policy.yaml for the authoritative version.

name: "financial"
version: "1.0.0"
description: "Banking and financial services policy: redacts 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: true

Key decisions:

  • IBAN, credit card, SSN, passport, and health ID are redacted, so they never reach the LLM, not even as tokens
  • Person names, companies, emails, and other identifiers are tokenized
  • Date of birth uses tokenize with restore_mode: masked: reversible per policy, shown masked at rehydration, not destroyed
  • Default restore mode is masked: character masking preserving length

Worked example: reject, redact, tokenize on one intake note

A law firm paralegal desk drafts matter summaries with an LLM. Some values may be tokenized and later restored (party names, case number), some must never reach the model at all (bank IBAN, card number, tax/SSN). The right behavior is not one blanket rule but a policy decision, and this example runs the same fictional intake note through three shipped policies to show the three dispositions policy authoring gives you: refuse the request, drop a value irreversibly, or protect it reversibly. It then rehydrates the reversible paths to prove what can and cannot come back. The example is examples/policy-block-python/. It uses only the Python standard library (urllib), so nothing needs installing beyond the runtime, and the model step is a deterministic stub.

Policyon_redactA level-1 identifier (IBAN, card, SSN)Party names / case number
strict-piirejectThe whole request fails with 403 GUARDAI_POLICY_DENIED. Nothing is processed.not reached
legalcontinueBecomes [REDACTED:type]; the request succeeds. It can never be restored.tokenized, restorable
defaulttokenizeIBAN/card tokenized but pinned to a masked restore; SSN redacted.tokenized, full restore

On every succeeding path a raw financial value never reaches the model. The only difference is whether the request is refused, the value is dropped irreversibly, or the value is protected reversibly.

Prerequisites and run command

Start the runtime, then run the client. Person detection needs the NER detector sidecar (apps/detector-py): the {{person:...}} token for the party names only appears when NER is active. The structured identifiers (IBAN, card, SSN, email, phone) and the case_ref custom pattern are caught by the built-in regex detectors even without NER. The bundled Docker stack runs the detector in both mode. Two of the three steps use policies that declare person detection as required (strict-pii and legal), so on a detector outage those steps fail closed (HTTP 503) rather than continue with missing names; only the default step, which does not require NER, falls back to the built-in detector. Run the full stack with the detector active.

# Start the OGuardAI runtime. Person detection needs the NER sidecar (see the note below). The bundled
# stack requires these four secrets, and its client key ships least-privilege, so widen it to the
# policies and channels this example uses.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1_ALLOWED_POLICIES=default,default-ner-required,german-support,strict-pii,legal
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,internal_summary
docker compose -f deploy/docker/docker-compose.yml up -d

export GUARDAI_URL=http://localhost:3000
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth
python main.py

Check detection is wired with curl localhost:3000/v1/health: the detector component should report builtin_and_ner.

The intake fixture

examples/policy-block-python/legal_intake.txt. All values are fictional.

Matter intake, case 3 O 271/25, contract dispute.

Client: Ms. Erika Mustermann (email erika.mustermann@example.com, phone +49 89 55598877).
Opposing party: Max Mustermann. A retainer was received on the client account
IBAN DE97 9900 0000 0123 4567 89. The client also sent a card number
4111 1111 1111 1111 and, for the engagement letter, her tax/SSN 123-45-6789.
Please summarize the matter for the supervising attorney.

Configuration and transport

The client reads the runtime URL and optional API key from the environment and lists the seven raw values that must never appear in safe_text. From examples/policy-block-python/main.py:

from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from pathlib import Path

BASE = (os.environ.get("GUARDAI_URL") or os.environ.get("GUARDAI_BASE_URL") or "http://localhost:3000").rstrip("/")
API_KEY = os.environ.get("GUARDAI_API_KEY", "")
HERE = Path(__file__).resolve().parent
INTAKE_FILE = HERE / "legal_intake.txt"

# Raw values that must never survive into safe_text on any succeeding path. The structured
# identifiers are caught by the built-in regex detectors, so a structured leak is a hard failure in
# every mode. The names need the NER detector, so a name leak is a hard failure when the runtime
# reports NER active, and a printed note otherwise, since running without NER is a valid deployment.
NER_SENSITIVE = ["Erika Mustermann", "Max Mustermann"]
STRUCTURED_SENSITIVE = [
    "erika.mustermann@example.com",
    "+49 89 55598877",
    "DE97 9900 0000 0123 4567 89",
    "4111 1111 1111 1111",
    "123-45-6789",
]
SENSITIVE = NER_SENSITIVE + STRUCTURED_SENSITIVE

RULE = "=" * 78

The HTTP helpers return 4xx/5xx bodies instead of raising, so the reject path (403) is observable, and the boundary guard raises SystemExit if a raw value ever crosses into safe_text. From examples/policy-block-python/main.py:

def _headers() -> dict[str, str]:
    headers = {"Content-Type": "application/json"}
    if API_KEY:
        headers["X-API-Key"] = API_KEY
    return headers


def _post(path: str, payload: dict) -> tuple[int, dict]:
    """POST JSON and return (status, body). 4xx/5xx are returned, not raised, so the reject path
    (403) is observable; a genuine connection failure raises urllib.error.URLError to main."""
    request = urllib.request.Request(
        BASE + path, data=json.dumps(payload).encode(), headers=_headers(), method="POST"
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return response.status, json.loads(response.read().decode())
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode()
        try:
            return exc.code, json.loads(raw)
        except json.JSONDecodeError:
            return exc.code, {"error": raw}


def _get(path: str) -> tuple[int, dict]:
    """GET JSON and return (status, body). 4xx/5xx are returned, not raised; a genuine connection
    failure raises urllib.error.URLError to main."""
    request = urllib.request.Request(BASE + path, headers=_headers(), method="GET")
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return response.status, json.loads(response.read().decode())
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode()
        try:
            return exc.code, json.loads(raw)
        except json.JSONDecodeError:
            return exc.code, {"error": raw}


def fetch_ner_active() -> bool:
    """The one leak-exemption signal: /v1/capabilities.ner_active reports whether this runtime is
    configured to detect person names. If the field is absent or the endpoint errors, treat NER as
    available so a name leak still fails closed: a protection example must never print an assurance
    it cannot justify."""
    code, body = _get("/v1/capabilities")
    if code != 200 or not isinstance(body, dict):
        return True
    value = body.get("ner_active")
    return value if isinstance(value, bool) else True


def transform(text: str, policy: str) -> tuple[int, dict]:
    return _post("/v1/transform", {"input": text, "policy": policy})


def rehydrate(output: str, session_state: str, *, channel: str) -> tuple[int, dict]:
    return _post(
        "/v1/rehydrate",
        {"output": output, "session_state": session_state, "output_channel": channel},
    )


def check_boundary(safe_text: str, ner_active: bool) -> list[str]:
    """Guard the text the model is about to see. A structured identifier crossing is a hard failure
    on every lane. A person name crossing is a hard failure when NER is active, and a printed note
    when the runtime runs without NER, a valid deployment. Returns the name notes so the closing
    summary stays honest."""
    structured_leak = [value for value in STRUCTURED_SENSITIVE if value in safe_text]
    if structured_leak:
        raise SystemExit(f"FAIL: structured PII reached the model: {structured_leak}")
    name_leak = [value for value in NER_SENSITIVE if value in safe_text]
    if not name_leak:
        print(f"  leak check: PASS, none of the {len(SENSITIVE)} raw values present")
        return []
    if ner_active:
        raise SystemExit(f"FAIL: a person name reached the model while NER is active: {name_leak}")
    for value in name_leak:
        print(f"  NOTE: the name {value} reached the model because this runtime runs without NER")
    return name_leak


def token_of(safe_text: str, type_prefix: str) -> str | None:
    marker = "{{" + type_prefix
    start = safe_text.find(marker)
    if start == -1:
        return None
    end = safe_text.find("}}", start)
    return safe_text[start : end + 2] if end != -1 else None

The three dispositions

Each disposition is one POST /v1/transform with a different policy, followed on the reversible paths by a POST /v1/rehydrate that names an output_channel. internal_summary restores the attorney view; user_output restores the client view where IBAN stays masked by policy. The final check corrupts the session blob and confirms the rehydrate fails closed rather than half-restoring. From examples/policy-block-python/main.py:

def section(title: str) -> None:
    print("\n" + RULE)
    print(title)
    print(RULE)


def run_strict(intake: str) -> None:
    section("1) strict-pii  (on_redact: reject)  -> the whole request fails closed")
    code, body = transform(intake, "strict-pii")
    if code == 403:
        print(f"  HTTP {code}  code={body.get('code')}  error={body.get('error')}")
        print("  A level-1 identifier (IBAN/card/SSN) was present, so nothing was processed and the")
        print("  model was never called. This is the fail-closed disposition for regulated inputs.")
    else:
        raise SystemExit(f"strict-pii did not reject a level-1 identifier: HTTP {code} {json.dumps(body)[:200]}")


def run_legal(intake: str, ner_active: bool) -> list[str]:
    section("2) legal  (on_redact: continue)  -> financials redacted, names tokenized + restorable")
    code, body = transform(intake, "legal")
    if code != 200:
        raise SystemExit(f"legal transform failed unexpectedly: HTTP {code} {json.dumps(body)[:200]}")
    safe_text = body.get("safe_text", "")
    print("  safe_text (what the model sees):\n")
    print("    " + safe_text.replace("\n", "\n    "))
    print(f"\n  entities_blocked (redacted, never a token): {body.get('stats', {}).get('entities_blocked')}")
    notes = check_boundary(safe_text, ner_active)

    session = body.get("session_state") or ""
    if not session:
        print("  no session_state; skipping restore")
        return notes

    # Draft a summary reusing only the tokens the model saw, then restore it. Party names come back;
    # the redaction markers cannot be un-redacted, because a redacted value never entered the session.
    case = token_of(safe_text, "case_ref:") or "the matter"
    client_tok = token_of(safe_text, "person:") or "the client"
    summary = (
        f"Summary for {case}: the client {client_tok} has retained the firm. Financial identifiers "
        f"(IBAN, card, tax id) were redacted at intake and are not reproduced here."
    )
    rcode, restored = rehydrate(summary, session, channel="internal_summary")
    print("\n  restored on internal_summary (attorney view):")
    print("    " + restored.get("restored_text", "").replace("\n", "\n    "))
    print(
        f"    tokens_resolved={restored.get('tokens_resolved')} unresolved={restored.get('tokens_unresolved')}"
        if rcode == 200
        else f"    restore failed HTTP {rcode}: {restored.get('code')}"
    )
    return notes


def run_default(intake: str, ner_active: bool) -> tuple[str, list[str]]:
    section("3) default  (tokenize)  -> reversible protection, IBAN/card restore masked")
    code, body = transform(intake, "default")
    if code != 200:
        raise SystemExit(f"default transform failed unexpectedly: HTTP {code} {json.dumps(body)[:200]}")
    safe_text = body.get("safe_text", "")
    print("  safe_text (what the model sees):\n")
    print("    " + safe_text.replace("\n", "\n    "))
    print()
    notes = check_boundary(safe_text, ner_active)

    session = body.get("session_state") or ""
    if not session:
        print("  no session_state; skipping restore")
        return "", notes

    person = token_of(safe_text, "person:") or "the client"
    iban = token_of(safe_text, "iban:")
    reply = (
        f"Confirmation for {person}: the retainer on account {iban} is recorded."
        if iban
        else f"Confirmation for {person}: the retainer is recorded."
    )
    rcode, restored = rehydrate(reply, session, channel="user_output")
    print("\n  restored on user_output (name full, IBAN masked by policy):")
    print("    " + restored.get("restored_text", "").replace("\n", "\n    "))
    return session, notes


def run_fail_closed(session: str) -> None:
    section("4) fail-closed: a tampered session is rejected, not half-restored")
    if not session:
        print("  (no session available to tamper with; skipped)")
        return
    tampered = session[:-8] + "deadbeef"
    code, body = rehydrate("Confirmation for {{person:p_001:deadbeef0000}}.", tampered, channel="user_output")
    if code >= 400:
        print(f"  HTTP {code}  code={body.get('code')}  (rejected; no raw value returned)")
    else:
        raise SystemExit(f"fail-closed check failed: a tampered session was accepted (HTTP {code})")


def main() -> int:
    intake = INTAKE_FILE.read_text(encoding="utf-8").strip()
    print(f"OGuardAI policy dispositions   runtime: {BASE}")
    notes: list[str] = []
    try:
        ner_active = fetch_ner_active()
        run_strict(intake)
        notes += run_legal(intake, ner_active)
        session, default_notes = run_default(intake, ner_active)
        notes += default_notes
        run_fail_closed(session)
    except urllib.error.URLError as exc:
        print(f"\ncannot reach the OGuardAI server at {BASE}: {exc.reason}")
        print("start it first with docker compose (see the README).")
        return 1

    print("\n" + RULE)
    print("Same intake, three dispositions: rejected outright, redacted-and-continued, or reversibly")
    print("tokenized. Redacted values never returned; tokens restored only where policy allows.")
    if notes:
        leaked_names = sorted(set(notes))
        print("Structured PII stayed inside the runtime; the name(s) "
              f"{leaked_names} reached the model because this runtime runs without NER.")
    print(RULE)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Expected output

Token ids and fingerprints vary per run.

1) strict-pii  (on_redact: reject)  -> the whole request fails closed
  HTTP 403  code=GUARDAI_POLICY_DENIED  error=policy denied: blocked entities detected

2) legal  (on_redact: continue)  -> financials redacted, names tokenized + restorable
  safe_text (what the model sees):
    Matter intake, case {{case_ref:cr_001:...}}, contract dispute.
    Client: {{person:p_001:...}} (email {{email:e_001:...}}, phone {{phone:ph_001:...}}).
    Opposing party: {{person:p_002:...}}. A retainer was received on the client account
    IBAN [REDACTED:iban]. The client also sent a card number [REDACTED:credit_card] and,
    for the engagement letter, her tax/SSN [REDACTED:ssn].
  entities_blocked (redacted, never a token): 3
  leak check: PASS, none of the 7 raw values present
  restored on internal_summary (attorney view):
    Summary for 3 O 271/25: the client Erika Mustermann has retained the firm. ...
    tokens_resolved=2 unresolved=[]

3) default  (tokenize)  -> reversible protection, IBAN/card restore masked
  ...
  restored on user_output (name full, IBAN masked by policy):
    Confirmation for Erika Mustermann: the retainer on account IBAN **** (masked) is recorded.

4) fail-closed: a tampered session is rejected, not half-restored
  HTTP 400  code=GUARDAI_INVALID_INPUT  (rejected; no raw value returned)

strict-pii never calls the model. legal redacts the three financial identifiers to [REDACTED:...] (absent from the session, so a later rehydrate restores the names but can never bring those values back) and tokenizes the names for restore. default tokenizes names for a full restore and keeps IBAN and card as tokens pinned to masked, so they show as a mask even when the reply is rehydrated. The tampered session is rejected with 400 GUARDAI_INVALID_INPUT, never half-restored.

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 template

Policy 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.