Redactions
A Redactions store is one conversation's private memory of the personal data
that jes hid from the model.
Its job
When pii() finds a value such as an email address, jes replaces it with a
placeholder before the text reaches your model or any judge. The model works
with [JES_PII_…] instead of ana@example.com.
The real value has to live somewhere, or nothing could put it back. That
place is the Redactions store. It has three jobs:
- Remember: map each placeholder to the value it replaced.
- Restore: when the model's reply uses a placeholder,
check_outputputs the real value back for the user. - Stay consistent: the same value always gets the same placeholder in one store, so across many turns the model sees one stable stand-in.
It does not decide what counts as personal data. That is the job of the
policies (pii, secrets). It also never hands values out on
request. Values leave only through output restoration, or encrypted through
dumps.
Mental model
A store is a coat check for one conversation. jes takes the value, gives the model a ticket, and hands the value back only when that ticket comes back in a reply.
A ticket is an HMAC of the value under the store's own random secret. No other store can produce or redeem it. A placeholder that a user types, or that turns up in a retrieved document, is never trusted. It is treated as literal text and flagged.
Using it
Create one store per conversation, and pass it to every check in that conversation:
from jes import Guard, Redactions
from jes.policies import pii
guard = Guard([pii(["EMAIL_ADDRESS"])])
store = Redactions(scope=b"tenant-42:conversation-7") # one per conversation
incoming = guard.check_input("Email me at ana@example.com", redactions=store)
reply = call_model(incoming.onward) # the model sees "[JES_PII_…]"
outgoing = guard.check_output(reply, prompt=incoming, redactions=store)
show_user(outgoing.onward) # the real email is back
If your app is stateless between requests, save the store encrypted, and load
it on the next turn. This needs jes[crypto]:
blob = store.dumps(key, associated_data=b"tenant-42") # key: 32 bytes
# … next request …
store = Redactions.loads(
blob, key, scope=b"tenant-42:conversation-7", associated_data=b"tenant-42"
)
Good to know
- One store per conversation.
- A store shared across conversations lets one conversation's tickets resolve in another.
- Passing a store that doesn't match the one on the
prompt=result raisesRedactionError.
- You can skip the store for a single turn. Without one,
check_inputcreates an in-memory store. It is kept onresult.redactionsand reused when you pass that result asprompt=. - A blocked check leaves the store unchanged. New entries are committed only when the check is complete and allowed.
- It can't be copied or pickled. This is on purpose, so values don't leak through a copy. Use
dumps. - It is bounded.
- Past
max_entries(1,000) ormax_bytes(8 MiB), a check blocks withredaction_store_full. - More than
Limits(max_redactions=...)values in one check block withtoo_many_redactions. - Neither raises an error.
- Past
- Tool-call arguments are never restored. jes doesn't fill real values into a tool call. Your code authorizes and fills tool inputs.
Next
- Redactions reference: the constructor,
dumps,loads, andTOKEN_RE. - Multi-turn conversations: history, output rules, and persistence.
- PII across one conversation: a runnable lesson.