OGuardAI
GuidesCase Studies

Financial Compliance

How a bank uses AI for sanctions screening and AML checks without exposing customer financial identifiers to the language model

How a bank uses AI for sanctions screening and AML checks without exposing customer financial identifiers to the language model.


Note: This scenario relies on detecting sender/recipient names. Person and company detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, with detector.mode: both or advanced); in builtin-only mode names are not detected (IBAN, BIC, and national/tax IDs still are, via regex). Set detection.required_for: [person] in the policy to fail closed if the sidecar is down. See Detector Capabilities.

The Situation

A mid-size European bank processes 1,500 wire transfer requests per day. Each request contains the sender's name, IBAN, recipient IBAN, BIC/SWIFT code, and sometimes a national ID or tax number in the payment reference. The compliance team wants to use AI to pre-screen transfers against sanctions lists and flag suspicious patterns, reducing manual review from 45 minutes to under 5 minutes per case.

The regulator's position is clear: customer financial identifiers (IBANs, account numbers, national IDs) must not leave the bank's infrastructure to reach a third-party AI provider. The bank's information security team adds a further constraint: even internal AI models running in the bank's cloud must not receive raw IBANs in logs or training data.


The Solution

OGuardAI sits between the bank's transfer processing system and the compliance AI model. Financial identifiers are tokenized before reaching the model. The model receives enough context to make compliance decisions (country codes, transfer amounts, payment purpose) without seeing raw account numbers.


Step-by-Step: A Wire Transfer Review

A customer submits a wire transfer to a recipient in a high-risk jurisdiction.

Step 1: Transfer Request Arrives

Sender: Erika Mustermann
Sender IBAN: DE97 9900 0000 0123 4567 89
Recipient: Max Mustermann
Recipient IBAN: RU02 0445 2080 0000 0640 2357
BIC: TESTRUMM
Amount: EUR 48,500.00
Reference: Invoice INV-2026-0891, Tax ID 1234567890
Purpose: Consulting services Q1 2026

Step 2: OGuardAI Transforms the Request

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "Sender: Erika Mustermann\nSender IBAN: DE97 9900 0000 0123 4567 89\nRecipient: Max Mustermann\nRecipient IBAN: RU02 0445 2080 0000 0640 2357\nBIC: TESTRUMM\nAmount: EUR 48,500.00\nReference: Invoice INV-2026-0891, Tax ID 1234567890\nPurpose: Consulting services Q1 2026",
    "policy": "financial-compliance"
  }'

Detected entities:

Original ValueEntity TypeTokenMetadata
Erika Mustermannperson{{person:p_001:083e771529f3}}role: sender
DE97 9900 0000 0123 4567 89iban{{iban:ib_001:2f9c04d1be67}}--
Max Mustermannperson{{person:p_002:6a5b83e0d94c}}role: recipient
RU02 0445 2080 0000 0640 2357iban{{iban:ib_002:91d3f7a2c05e}}--
TESTRUMMbic*{{custom:bic:x_001:3e8f60b2d7a1}}--
1234567890tax_id*{{custom:tax_id:x_002:b47a1c92e05d}}--

Entity types marked with * are custom types defined via policy rules, not built-in. See the Extending Entities guide.

The only metadata here is role, and it exists because the policy below declares a roles: block and sets expose_role: true. Roles are a deployment's own taxonomy, inferred from the words around the entity, never from its value. The runtime derives no country, bank, or institution metadata from an IBAN or a BIC: the token metadata fields are gender, formality, language, role, belongs_to, legal_form, rtl, and honorific, and nothing else.

The safe text sent to the AI:

Sender: `{{person:p_001:083e771529f3}}`
Sender IBAN: `{{iban:ib_001:2f9c04d1be67}}`
Recipient: `{{person:p_002:6a5b83e0d94c}}`
Recipient IBAN: `{{iban:ib_002:91d3f7a2c05e}}`
BIC: `{{custom:bic:x_001:3e8f60b2d7a1}}`
Amount: EUR 48,500.00
Reference: Invoice INV-2026-0891, Tax ID `{{custom:tax_id:x_002:b47a1c92e05d}}`
Purpose: Consulting services Q1 2026

The invoice number and purpose pass through: no detector matches them. The transfer amount is a detected money entity, so it stays raw here only because the policy whitelists it (see the whitelist block below): compliance analysis needs the exact figure to apply reporting thresholds. Without the whitelist entry the amount would be tokenized like every other detected value.

The role metadata tells the model which party is the sender and which is the recipient. It does not tell the model where either party is: jurisdiction is not derived from an IBAN. If the screening step needs the country, the application must supply it out of band, for example as a separate structured field the model sees alongside the tokenized text.

Step 3: AI Performs Compliance Analysis

The bank's screening service sends the tokenized transfer, the entity context, and the sender and recipient jurisdictions as a separate structured field. The jurisdictions come from the bank's own payment rails, not from OGuardAI: no country is derived from an IBAN or a BIC. The model generates:

COMPLIANCE SCREENING REPORT

Transfer: `{{person:p_001:083e771529f3}}` (DE) -> `{{person:p_002:6a5b83e0d94c}}` (RU)
Amount: EUR 48,500.00

Risk Flags:
1. HIGH - Recipient jurisdiction (RU) is on enhanced due diligence list
2. MEDIUM - Amount exceeds EUR 15,000 threshold for enhanced monitoring
3. LOW - Payment reference contains tax identifier from high-risk jurisdiction

Sanctions Check:
- `{{person:p_001:083e771529f3}}`: No match found in OFAC/EU sanctions lists
- `{{person:p_002:6a5b83e0d94c}}`: No exact match. Partial name similarity to sanctioned
  entity "D. Volkov" (SDN List ref. 29847): requires manual verification.

Recommendation: HOLD for manual review. Escalate to Level 2 compliance
officer for recipient identity verification before processing.

Required Actions:
- Verify recipient identity against passport/ID documentation
- Confirm business relationship (consulting agreement on file?)
- Document enhanced due diligence findings

The AI made a meaningful compliance decision from the jurisdictions the application supplied plus the risk patterns in the tokenized text, without ever seeing the actual IBANs or tax ID.

Step 4: Restore for Different Channels

Internal compliance system, full restore:

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

The compliance officer sees the full report with the names, IBANs, and BICs restored for the manual review step. The tax ID does not come back: its base restore mode is none, and a channel rule can only tighten, so no channel can lift it.

Audit log, no restore at all:

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

The audit log records the screening decision, risk flags, and recommendation. log_safe is clamped to none by the runtime, so every token in the report resolves to [REDACTED] and the log copy carries no party identity at all. When an audit record has to keep a recognizable stub, send it to export instead: that channel is clamped to masked, which keeps only the first and last character (D*********************0). A last-four display needs partial plus a reveal_last_n restore strategy, which the two safe sinks can never reach, so route it to internal_summary or a custom channel.


Policy Configuration

name: financial-compliance
version: "1.0"
# A channel rule can only TIGHTEN an entity's base restore_mode, never widen it. person,
# iban, and bic carry a base of "full" because the internal_summary channel below has to
# restore them for the manual review step; every other channel narrows them back down.
# tax_id keeps a base of "none", so it is unrecoverable on every channel by design.
rules:
  - entity_type: "person"
    protection_level: 2
    action: "tokenize"
    restore_mode: "full"
  - entity_type: "iban"
    protection_level: 1
    action: "tokenize"
    restore_mode: "full"
  - entity_type: "bic"
    protection_level: 2
    action: "tokenize"
    restore_mode: "full"
  - entity_type: "tax_id"
    protection_level: 2
    action: "tokenize"
    restore_mode: "none"
  - entity_type: "ssn"
    protection_level: 1
    action: "redact"
  - entity_type: "passport"
    protection_level: 1
    action: "redact"

# bic and tax_id are bank-defined custom types. They must be declared in
# custom_patterns below before any rule or channel references them, otherwise the
# policy loader aborts startup with "references unknown entity type".
custom_patterns:
  - entity_type: "bic"
    pattern: '(?i)\b(?:bic|swift)[ \t:#.-]{0,4}([A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?)\b'
    confidence: 0.9
    value_group: 1
    context_words: ["bic", "swift"]
  - entity_type: "tax_id"
    pattern: '(?i)\b(?:tax id|tax-id|steuer-?id)[ \t:#.-]{0,4}(\d{9,11})\b'
    confidence: 0.9
    value_group: 1
    context_words: ["tax", "steuer"]

# The role metadata in Step 2 comes from here. There is no built-in role taxonomy: a policy
# that declares no roles produces none. Cues match against a context window in which the
# entity span is replaced by the literal {{ENTITY}} sentinel, so a cue never sees the raw
# value. Only anchored_regex is used: a context_word matches anywhere in the window, and on
# this input "Sender IBAN:" sits inside the recipient's window, so a bare "sender" word cue
# would win on priority and mislabel the recipient. An anchored cue is positional, and the
# [^.\n] class stops it from reaching across the line break.
roles:
  - name: "sender"
    applies_to: ["person"]
    priority: 100
    anchored_regex:
      - '(?i)\b(?:sender|originator|auftraggeber)\b[^.\n]{0,30}\{\{ENTITY\}\}'
  - name: "recipient"
    applies_to: ["person"]
    priority: 90
    anchored_regex:
      - '(?i)\b(?:recipient|beneficiary|empfaenger)\b[^.\n]{0,30}\{\{ENTITY\}\}'

defaults:
  protection_level: 2
  action: "tokenize"
  restore_mode: "masked"

# The transfer amount is a detected money entity. Whitelisting the literal value
# is the only path that keeps a detected value raw across the boundary (an
# authored action: allow is rejected at load), so the model can apply reporting
# thresholds to the exact figure.
whitelist:
  - entity_type: money
    value: "EUR 48,500.00"

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

# log_safe is deliberately absent: that channel is clamped to "none" by the runtime, so
# every entity restores to [REDACTED] there regardless of what a rule would ask for.
channel_rules:
  internal_summary:
    person: full
    iban: full
    bic: full
  customer_email:
    person: full
    iban: masked
    bic: none
    tax_id: none

What OGuardAI Made Possible

Regulatory compliance without crippling AI. IBANs and tax identifiers never leave the bank's trust boundary. The AI still receives the jurisdictions as a separate structured field from the bank's own systems, enough to flag jurisdiction risk and check sanctions patterns.

Separation of screening and identity. The AI evaluates risk patterns (high-risk country, amount thresholds, name similarity) without accessing the actual financial identifiers. This satisfies both the regulator and the bank's infosec policy.

Channel-specific access control. Compliance officers restore the identifiers they need for manual review. The audit copy goes to log_safe, where nothing restores, so the record keeps the decision without the identities. Customer notifications reveal only what the customer should see.

Redacted entity types. SSN and passport numbers in payment references are redacted entirely (replaced with [REDACTED:type] and purged): they never reach the model, not even as tokens. The policy enforces this at the engine level.