Reversible masking¶
hash, redact and mask are one-way: once the text leaves the anonymizer the
value is gone from it for good. Action.TOKENIZE is the reversible one — each
value becomes a numbered placeholder and the mapping stays on the ScanResult,
in memory, on your side of the wire.
That is the shape of an LLM round trip: mask on the way out, restore on the way back, with the model never seeing a real value.
Masking¶
from wardcat import Wardcat, Entity, Action
guard = (
Wardcat(salt="s")
.add_entities([Entity.EMAIL, Entity.CREDIT_CARD], action=Action.TOKENIZE)
)
result = guard.scan("Mail ali@example.com about card 4532 0151 1283 0366")
result.sanitized_text
# 'Mail [EMAIL_1_9f3a2c8b71d4] about card [CREDIT_CARD_1_9f3a2c8b71d4]'
A placeholder is [TYPE_index_contextid]. The index is per entity type in
order of appearance, and the same value always gets the same token — a name that
repeats stays one referent, so the model can still reason about it:
guard.scan("Ali wrote to Veli; reply to Ali").sanitized_text
# '[PERSON_1_4c81…] wrote to [PERSON_2_4c81…]; reply to [PERSON_1_4c81…]'
The index restarts at 1 for every scan, so it stays short; the context id is
drawn once per scan (result.context_id) and shared by that scan's tokens. See
Why the context id — it is the difference between a
misrouted answer restoring the wrong person's data and restoring nothing at all.
Which layer found the value does not matter: anonymization runs after detection,
so a span from regex, SpaCy NER or the LLM layer is tokenized and restored the
same way. Only the confidence in the source list gives the layer away — 1.00
for a checksum-validated regex match, 0.85 for a model-based one.
Restoring¶
I have emailed ali@example.com about the charge on 4532 0151 1283 0366.
--- Sources ---
[1] [EMAIL_1_9f3a2c8b71d4] → ali@example.com (EMAIL · tokenize · confidence 0.97)
[2] [CREDIT_CARD_1_9f3a2c8b71d4] → 4532 0151 1283 0366 (CREDIT_CARD · tokenize · confidence 1.00)
Printing a RestoredText appends that source list: each placeholder that was put
back, in the order it appears in the text, with the filter that caught it, the
action applied, the value it stood for, an xN count when it occurs more than
once, and the detection confidence.
.text |
the restored text alone |
.sources_block() |
the list alone — title= renames it, notes=False drops the trailing summary |
.with_sources() |
text + list; what str(restored) returns |
.substitutions |
the same information as Substitution records |
.unrestored |
detected values that were not put back, and why |
.is_complete |
False if a placeholder was left sitting in the text (ambiguous or foreign) |
restore() with no argument reverses sanitized_text itself, which round-trips
back to the original input — a cheap way to assert a policy is reversible in
tests.
Why the context id¶
Two requests arriving together both hold an "EMAIL number 1". If placeholders
were just [EMAIL_1], the two scans would produce the same string, and
restoring one request's answer against the other's result would match it and
substitute the wrong person's address — silently, with no error and nothing in
unrestored. That is the worst outcome this library can produce.
The context id makes those tokens distinct, so the mistake has nothing to match:
mine = guard.scan("Ali: ali@example.com") # [EMAIL_1_9f3a2c8b71d4]
theirs = guard.scan("Beyza: beyza@example.com") # [EMAIL_1_4c81b70a2e55]
theirs.restore("Mailed [EMAIL_1_9f3a2c8b71d4].")
# text unchanged — nothing was substituted
# unrestored [UnrestoredValue(entity_type='EMAIL', reason='foreign', …)]
# is_complete False
The id is 12 hex characters — 48 bits, so with a hundred thousand results alive at once the chance any two collide is about 1.8e-5, and request-scoped use puts it near 1e-9. It is drawn per scan, needs no shared counter, and holds across processes.
Two knobs:
result.restore(answer, strict=True) # raise ContextMismatch instead of reporting
turn2.restore(answer, also=[turn1]) # an earlier turn's tokens are legitimate here
strict=True is the right default for a request handler. also= is for
multi-turn exchanges, where an answer may carry placeholders from a previous
scan in the same conversation — those are restored too, and anything else still
raises.
A mangled token is covered by the same mechanism: if the model returns
[EMAIL_1_9f3a2c8b71d5] for [EMAIL_1_9f3a2c8b71d4], there is no match, so the
value simply does not come back. The model's accuracy decides how much is
restored; it can never decide whether what comes back is correct.
Prompting the model to keep placeholders¶
Restoring needs the model to reproduce the placeholders verbatim. Whether it does is a property of the model — and, more than anything, of the instruction you give it. Measured on a local Qwen2.5-1.5B with the same text and three runs each:
| instruction | placeholders preserved |
|---|---|
| "Copy every placeholder in square brackets EXACTLY as written." | 0 / 9 |
"The text contains placeholders like [EMAIL_1]. Copy every placeholder EXACTLY as written, character for character. Never invent placeholders." |
6 / 9 |
One model, three runs per instruction, nine placeholder slots each: the direction is worth acting on, the exact rate is not. A starting point:
INSTRUCTION = (
"The text contains placeholders like [EMAIL_1_9f3a2c8b71d4]. "
"Copy every placeholder EXACTLY as written, character for character. "
"Never invent placeholders and never renumber them."
)
answer = call_llm(INSTRUCTION + "\n\n" + payload.sanitized_text)
Two things this measurement settled:
The context id does not cost recall. The same comparison with the id stripped
out preserved fewer placeholders, not more — consistent with the failure mode
seen elsewhere, where a model turns [CREDIT_CARD_1] into [CREDIT_CARD_2] but
copies [CREDIT_CARD_49] untouched. A trailing hex suffix reads as an opaque
identifier to copy rather than an item to renumber.
A model too weak to copy placeholders is not a safety problem. Small models
paraphrase them away or drop them entirely; Qwen2.5-0.5B and SmolLM2-135M both
did in testing. Nothing wrong is substituted — the value simply does not come
back, and it is reported in .unrestored. Check it, and decide what your
application does about a partial restore:
restored = payload.restore(answer)
if restored.unrestored:
log.warning("partial restore: %s", [(u.entity_type, u.reason) for u in restored.unrestored])
Which actions can be reversed¶
| Action | Reversible | Why |
|---|---|---|
tokenize |
always | one placeholder per distinct value, by construction |
hash |
in practice | a salted digest differs per distinct value |
redact |
only if unique | every value of a type collapses onto [TYPE] |
mask |
only if unique | two values can mask to the same string |
warn |
nothing to do | the original text was never replaced |
A placeholder that stood for more than one value is not guessed: it is left
in the text and reported in .unrestored with reason ambiguous (and called out
under the source list). Scanned with a one-way action and want a reversible view
without re-detecting? result.reapply(Action.TOKENIZE) reuses the spans and
re-runs only the anonymization stage.
Keep the payload uniform¶
A guard usually mixes actions, and with_llm() in particular switches on its own
entity policy — one whose defaults include warn. warn reports a value without
replacing it, so a scan you believe is masked can hand the model a phone number
in the clear:
[PERSON:5687e6a708553da8], [EMAIL_1_9f3a…], tel +90 532 123 45 67
PERSON hash EMAIL tokenize PHONE warn ← still in the text
Deriving a uniformly tokenized payload closes that, and makes every token short and alike while it is at it. Detection is not repeated, only the cheap anonymization stage:
result = guard.scan(text)
payload = result.reapply(Action.TOKENIZE) # every span becomes [TYPE_N]
answer = call_llm(payload.sanitized_text)
print(payload.restore(answer)) # restore through the same object
mixed : [PERSON:5687e6a708553da8], [EMAIL_1_9f3a…], tel +90 532 123 45 67
uniform : [PERSON_1_0c29…], [EMAIL_1_0c29…], tel [PHONE_1_0c29…]
Restore through the object you sent — payload, not result: the placeholders in
the answer are the ones reapply produced.
Handing the map to another process¶
restore() needs the ScanResult. When the answer comes back somewhere else —
a queue worker, another request — carry the map yourself:
vault = result.token_map # {'[EMAIL_1_9f3a2c8b71d4]': 'ali@example.com'}
context = result.context_id # carry it too, to spot a token from another scan
The reverse map is raw PII
token_map, restore() and the source list all carry the original values by
design — that is what reversibility means. Keep them on the trusted side, out
of logs and telemetry (redacted() stays the safe
view), and prefer a one-way action when you do not need the values back.
Placeholder numbering is per scan, so a ScanResult is the only thing that
can reverse its own text.