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: hashLoaded 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:
| Syntax | Meaning |
|---|---|
$ | Root of the request body |
.field | Child 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_NUMBERdetect_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: hashhash 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, orreplace_with_synthetic. Two is a config error; zero is a config error. detect_via_regexmust compile.replace_with_synthetic.formatmust render withn=1.preserve_componentsrequiresdetect.- 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 withload_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 throughsession.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_componentsis 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.