How dendrux keeps personal data out of the LLM provider API while still letting tools operate on real values, via a run-scoped bidirectional mapping.
PII redaction
When a PII guardrail finds an entity in a message, it does not just flag it. It replaces the entity with a placeholder like <<EMAIL_ADDRESS_1>> in the payload about to hit the LLM provider API, records the bijection in a per-run table, and keeps that table alive for the entire lifecycle of the run. The placeholder is what the LLM provider sees. The original value survives in the database and inside the runtime, to be substituted back at the one place it is needed: the tool function that has to actually operate on the real thing.
This page walks through the full round-trip with a real run, and explains where redaction takes effect versus where it does not.
The round-trip, end to end
A concrete scenario: user asks the agent to send_email to a real address. Guardrail is a default PII() (action redact). The run succeeds. Here is exactly what happened at each layer, captured from a live execution:
1. What the tool function received at runtime
to: 'jane.doe@example.com'
body: 'Your refund is processed.'The Python function ran with the real email. Redaction did not prevent the tool from doing its job.
2. What the LLM actually saw (llm_interactions.semantic_request)
iter=1 role=user content: "Please email <<EMAIL_ADDRESS_1>> saying 'Your refund is processed.'"The provider API call carried the placeholder, not the address. No 3rd-party API ever received the PII.
3. What the DB stored
agent_runs.pii_mapping:
{"<<EMAIL_ADDRESS_1>>": "jane.doe@example.com"}
react_traces (the authoritative transcript):
order=0 role=user content="Please email jane.doe@example.com saying 'Your refund is processed.'"
order=1 role=assistant tool_calls.params={'to': 'jane.doe@example.com', 'body': '...'}
order=2 role=tool content='"Sent email to jane.doe@example.com"'
tool_calls (proof of side effect):
send_email params={"to": "jane.doe@example.com", "body": "..."}The DB stores ground truth. The pii_mapping column is the audit key that lets a dashboard reconstruct what the LLM saw at any point.
Where the redaction boundary actually sits
The core invariant: redaction applies to the provider API wire, not to the database. The LLM is the only untrusted boundary. Everything else — the DB, the dashboard, traces, logs — is inside the developer's infrastructure.
This reflects a specific threat model: the concern is about sending PII to a third-party API (Anthropic, OpenAI, etc.), not about the app's own database. The app owns the DB. The app does not own the LLM provider.
If you want database-level redaction or encryption of persisted data, that is a separate concern — column-level encryption, at-rest policies, or custom serializers are the right tool for it. PII guardrails in dendrux are policy for the LLM boundary, not for persistence.
The pii_mapping column — audit key
agent_runs.pii_mapping is a JSON object on the run row. Placeholder to real, one entry per unique entity the guardrails have seen on this run. From the scratchpad above:
{
"<<EMAIL_ADDRESS_1>>": "jane.doe@example.com"
}Three invariants this mapping enforces:
- Stable tokens. If the same email appears in two different messages, the same
<<EMAIL_ADDRESS_1>>token is used both times. The guardrail engine checks the reverse index before minting a new placeholder. - Pause-safe. On resume, the runner reads
pii_mappingoff the row and reinitializes the guardrail engine with it. A second-iteration scan picks up the same placeholder for the same email, so the LLM never sees a redaction jitter between pause and resume. - Audit-replayable. With raw history in
react_tracesand the mapping on the run row, a dashboard can render every event in two views: LLM-eye (apply the mapping) and ground-truth (raw). The mapping is the key that links them.
From dendrux/runtime/runner.py:
# Load pii_mapping for guardrail continuity across pause/resume
_saved_pii_mapping = await state_store.get_pii_mapping(run_id)
...
initial_pii_mapping=_saved_pii_mapping,Deanonymization: the tool-params step
The LLM's tool call params carry placeholders (that is all it has ever seen). Dendrux walks the params dict recursively and substitutes back before the tool function runs. From dendrux/guardrails/_engine.py:
def deanonymize(self, params: dict[str, Any]) -> dict[str, Any]:
"""Replace <<PLACEHOLDER>> values with real values in tool call params.
Walks the dict recursively. Returns a new dict (does not mutate input).
If a placeholder is not in the mapping (corrupted by LLM), it passes
through unchanged — the caller emits a warning event.
"""
result = _deanonymize_value(params, self._pii_mapping)
return resultThree things that make this robust in practice:
- Recursive. The walk descends into nested dicts and lists. A param shape like
{"recipients": [{"email": "<<EMAIL_ADDRESS_1>>"}, ...]}round-trips correctly. - Tolerant of model corruption. If the LLM hallucinates a placeholder that does not exist in the mapping (e.g.
<<EMAIL_ADDRESS_42>>when only<<EMAIL_ADDRESS_1>>was ever minted), the string passes through unchanged. The loop emits a warning rather than crashing. - Non-mutating. Returns a new dict. Nothing the tool does to its params dict back-contaminates the LLM's view of the conversation.
The tool runs with the real values. The tool result goes into the DB raw, via on_message_appended and on_tool_completed. The next iteration's scan_incoming walks the full history and re-applies placeholders for the LLM — so the DB keeps ground truth while the provider API keeps seeing placeholders.
Regex vs Presidio
Two engines ship. Default is regex — zero dependencies, catches five canonical entities (EMAIL_ADDRESS, PHONE_NUMBER, US_SSN, CREDIT_CARD, IP_ADDRESS). Opt in to Presidio for NLP-backed detection of ~18 entities (adds PERSON, LOCATION, DATE_TIME, and others):
from dendrux.guardrails import PII
PII() # regex, default
PII(engine="presidio") # NLP-backed; requires dendrux[presidio]Entity names are canonical to Presidio's vocabulary across both engines, so dashboards and tests see one set of names regardless of backend. The pii_mapping, deanonymize behavior, pause/resume continuity, and governance events are identical.
Presidio's recall comes with statistical false positives — its PERSON recognizer can flag tool names or other non-PII tokens in prompt text. This is benign when the misclassified token is not something the LLM needs to reason about as a concrete string, which fits most prompts. When it matters, use the regex engine or stack both via the hybrid pattern.
See Guardrails — Choosing regex vs Presidio for the full trade-off table, false-positive walkthrough, and the stacked-engine recipe.
Where this fits with the rest of the system
PII redaction is not a standalone component. It is the interaction of three pieces:
- Guardrails run the actual scan and emit
guardrail.detectedevents. - State persistence stores the
pii_mappingcolumn onagent_runsand carries it through pause/resume. - Recorder writes raw traces — the mapping is the audit key, not a filter on persistence.
The redaction behavior is emergent from how these three cooperate. There is no PIIRedactor class. The guardrail engine owns the forward and reverse mappings; the recorder writes raw; the runner stitches them together.
Why a bijection instead of a one-way hash
An alternative would be to replace PII with a one-way hash (say, SHA-256 of the email) so the mapping table does not exist at all. That design does not work here for one reason: the tool function needs the real value.
You cannot send_email("sha256hex"). You cannot look up an order by a hashed customer id. The real value has to re-enter the runtime at the tool boundary. So the mapping has to be reversible, which makes it a bijection, which makes it a table.
The table is scoped to one run row. When the run is deleted, the mapping is deleted. When the run is cancelled, pause_data is cleared but pii_mapping persists on the terminal row so audit readers can still render the conversation in-context. The mapping's lifetime matches the audit trail's lifetime.