RedaktAIDocs

Per-field PII ontology

Declare project-specific PII rules keyed by JSONPath

By default, Rdakt scans every text field of an outbound LLM request with the same global detector pipeline. That works for free-form chat, but real payloads usually carry structured fields that need different treatment: internal IDs that should never reach the LLM, domain-specific identifiers that regex doesn't know about, addresses where you want to keep the city but mask the street.

The ontology lets you declare these rules per JSON path.

Where it goes

The ontology is just a top-level ontology: block in your rdakt.yaml, alongside pipeline, entities, session, etc.:

mode: active
pipeline:
  - regex

ontology:
  fields:
    - path: "$.messages[*].content"
      detect: ["EMAIL", "PHONE"]

    - path: "$.messages[*].metadata.user_id"
      replace_with_synthetic:
        type: USER_ID
        format: "user-{n:06d}"

    - path: "$.messages[*].context.case_number"
      detect_via_regex: '\bCASE-\d{4}-\d{6}\b'
      detect_via_regex_as: CASE_NUMBER
      mask_strategy: hash

Loaded the same way as the rest of the config:

from rdakt_ai import RdaktMiddleware, load_config

config = load_config("rdakt.yaml")
middleware = RdaktMiddleware(config=config)

Or built programmatically:

from rdakt_ai.config import (
    RdaktConfig,
    OntologyConfig,
    FieldRule,
    SyntheticReplacement,
)

config = RdaktConfig(
    ontology=OntologyConfig(
        fields=[
            FieldRule(
                path="$.messages[*].metadata.user_id",
                replace_with_synthetic=SyntheticReplacement(
                    type="USER_ID", format="user-{n:06d}"
                ),
            ),
        ]
    )
)

When ontology is set, the ontology pass runs before the global pipeline. Fields that an ontology rule already handled are then skipped by the global pass — they don't get scanned twice.

JSONPath subset

Rdakt accepts a small, safe slice of JSONPath:

SyntaxMeaning
$Root of the request body
.fieldChild by key
[*]Every element of an array

That's it. No filters, no recursive descent, no array indices. So $.messages[*].metadata.user_id works; $.messages[0], $..content, or $.messages[?(@.role=="user")] do not — they raise at config load with a clear error.

This is deliberate: the JSONPath is evaluated on every request, and the restricted grammar makes that pass cheap and obviously correct.

Rule shape

Each entry under ontology.fields is a FieldRule. It must declare exactly one detection source:

detect

Restrict the global detector pipeline to a subset of types at this path. Useful when one field can only ever contain certain entities and you want to skip the cost (and false positives) of running everything.

ontology:
  fields:
    - path: "$.messages[*].content"
      detect: ["EMAIL", "PHONE", "PERSON"]

detect_via_regex

An inline regex that produces entities only at this path. Sugar for a one-off pattern that isn't worth registering as a global custom entity.

ontology:
  fields:
    - path: "$.messages[*].context.case_number"
      detect_via_regex: '\bCASE-\d{4}-\d{6}\b'
      detect_via_regex_as: CASE_NUMBER

detect_via_regex_as names the entity type for the rendered placeholder (e.g. <CASE_NUMBER_1>). If omitted, defaults to CUSTOM.

replace_with_synthetic

Replace the whole field value with a generated synthetic. The original never reaches the LLM. Use this for opaque internal identifiers that have no natural surface form to detect.

ontology:
  fields:
    - path: "$.messages[*].metadata.user_id"
      replace_with_synthetic:
        type: USER_ID
        format: "user-{n:06d}"

format is a Python format string with a single {n} slot, filled with a per-(session, type) counter. So the first distinct user id becomes user-000001, the second user-000002, etc. Repeated values reuse the same token within a session.

Optional modifiers

mask_strategy

Override the default anonymizer strategy for this rule's matches. One of token (default), synthetic, hybrid, or hash.

ontology:
  fields:
    - path: "$.messages[*].context.case_number"
      detect_via_regex: '\bCASE-\d{4}-\d{6}\b'
      mask_strategy: hash

hash is salted with the session id, so the same input maps to the same token within a session but different tokens across sessions — preventing cross-session correlation.

preserve_components

(Validated at config load; runtime support is a follow-up.) For structured types like ADDRESS, names sub-components that should pass through unmasked. Only valid alongside detect.

ontology:
  fields:
    - path: "$.tools[*].arguments.address"
      detect: ["ADDRESS"]
      preserve_components: ["city", "state"]

Validation

Everything is checked at config load — your service will fail to boot rather than fail mid-request:

  • JSONPath must use the supported subset; bad paths raise with offset info.
  • A rule must declare exactly one of detect, detect_via_regex, or replace_with_synthetic. Two is a config error; zero is a config error.
  • detect_via_regex must compile.
  • replace_with_synthetic.format must render with n=1.
  • preserve_components requires detect.
  • Rules with no matching JSONPath are a no-op, not an error — so an ontology written for a richer payload doesn't break simpler requests.

Worked example: 4-rule payload

A request like:

{
  "messages": [
    {
      "content": "Hi, my email is alice@x.com",
      "metadata": {"user_id": "internal-id-9f8a3c"},
      "context": {"case_number": "CASE-2026-000001"}
    }
  ]
}

with the ontology at the top of this page becomes:

{
  "messages": [
    {
      "content": "Hi, my email is <EMAIL_1>",
      "metadata": {"user_id": "user-000001"},
      "context": {"case_number": "<CASE_NUMBER_HASH_18293042>"}
    }
  ]
}

— content was scanned with the EMAIL/PHONE subset, user_id was swapped wholesale, and case_number was hashed deterministically with the session salt.

The response from the LLM is deanonymized using the same session map, so the application code keeps seeing real values throughout — including the user-000001 ↔ internal-id-9f8a3c mapping for whole-value replacements.

Runnable example

A self-contained demo lives in the repo:

  • examples/configs/ontology.yaml — the YAML version of this guide's example, ready to load with load_config(...).
  • examples/ontology.py — a runnable script that builds the ontology programmatically, applies it to a sample request body, and round-trips a fake LLM response through session.deanonymize(...).

Run it with uv run python examples/ontology.py.

Limitations

  • Response-side ontology rules aren't supported; rules apply only to the outbound request body. Deanonymization on the response uses the session map populated during the request.
  • preserve_components is parsed and validated, but does not yet alter runtime behaviour. Track in RDA-7's follow-ups.
  • Custom detector plugins (beyond detect_via_regex) live in a separate effort.

On this page