Home

/

Keep PII Out of Your LLM

/

The Round Trip

The Round Trip

Chapter 9
Part III
4
min read
The round trip: detect and tokenise inside your perimeter, send only tokens to the provider, restore real values before the user sees the answer, with the token vault held inside the boundary

The shape

user text
   ↓
detect  →  replace entities with stable tokens  →  vault stores token → value
   ↓
prompt (tokens only) → model → completion (tokens only)
   ↓
restore tokens from vault (scoped to this request)
   ↓
answer the user sees

The model never sees a real name. The user never sees a token. The vault sits on your side of the boundary and is never reachable from anything the model influences.

Presidio gives you both halves. The anonymizer replaces findings with operators of your choosing, and its deanonymize endpoint reverses an encrypted or mapped replacement. You can also hold the mapping yourself, which is what the code below does, because the mapping is the asset and you want it under your own storage and access controls.

De-identifying

Take the findings from Chapter 6, sort them, and replace from the end of the string backwards so earlier offsets stay valid.

public sealed record Deidentified(
    string Text,
    IReadOnlyDictionary<string, string> Map);

public static Deidentified Deidentify(
    string text,
    IReadOnlyList<PiiFinding> findings)
{
    var map = new Dictionary<string, string>(StringComparer.Ordinal);
    var counters = new Dictionary<string, int>(StringComparer.Ordinal);
    var byValue = new Dictionary<string, string>(StringComparer.Ordinal);

    var sb = new StringBuilder(text);

    foreach (var f in findings.OrderByDescending(f => f.Start))
    {
        var original = text[f.Start..f.End];

        // Same value gets the same token, so coreference survives.
        if (!byValue.TryGetValue(original, out var token))
        {
            var n = counters.GetValueOrDefault(f.EntityType) + 1;
            counters[f.EntityType] = n;
            token = $"<{f.EntityType}_{n}>";
            byValue[original] = token;
            map[token] = original;
        }

        sb.Remove(f.Start, f.End - f.Start).Insert(f.Start, token);
    }

    return new Deidentified(sb.ToString(), map);
}

Three decisions are load-bearing.

Replace from the end. Offsets from the detector refer to the original string. Replacing forwards invalidates every subsequent offset the moment a token is a different length from the value it replaced, which it always is.

Same value, same token. The byValue dictionary is what preserves coreference. Without it, Sarah Whitfield mentioned three times becomes three different people and the summary is wrong.

Angle brackets, not bare words. <PERSON_1> survives a model paraphrasing the text. A bare PERSON_1 gets reformatted, lowercased, pluralised, or wrapped in quotes, and then your restore pass misses it. Delimiters that are unusual in prose are the difference between a restore that works and one that silently leaves tokens in the output.

Offsets are where this breaks

The Chapter 4 benchmark tested exactly this: redact, then restore, and check you get the original text back. Regex, Piiranha, GLiNER v1 and GLiNER v2 all passed 100% of the time.

Presidio passed 64%.

The cause was character offset misalignment, arising from how tokenisation maps back to character positions. That is fixable in post-processing, so treat it as a warning about offsets rather than a verdict on Presidio. What it should change is your confidence: assume nothing, and check.

Assert it rather than trusting it:

public static string Reidentify(
    string completion,
    IReadOnlyDictionary<string, string> map)
{
    var sb = new StringBuilder(completion);
    foreach (var (token, original) in map)
        sb.Replace(token, original);
    return sb.ToString();
}

// Round-trip invariant: deidentify then reidentify must be lossless.
[Fact]
public void RoundTrip_RestoresOriginalExactly()
{
    const string input = "Contact Sarah Whitfield at swhitfield@example.com about 021000021.";
    var findings = Detector.Detect(input);
    var d = Deidentify(input, findings);

    Assert.Equal(input, Reidentify(d.Text, d.Map));
    Assert.DoesNotContain("Sarah Whitfield", d.Text, StringComparison.Ordinal);
}

Run that over your Chapter 7 golden set, not over one example. A 64% pass rate means one in three of your requests comes back mangled, and the visible symptom is a corrupted answer rather than an error, so nothing alerts.

The vault

For a single request, a Dictionary on the stack is the vault, and that is the right answer for stateless calls. It lives for the duration of the request and is garbage collected. Nothing persists, so nothing leaks.

Conversations need more, because turn seven must use the same tokens as turn one. That means storage, and storage means the vault becomes an asset.

public interface ITokenVault
{
    Task<string> TokenForAsync(
        string conversationId, string entityType, string value, CancellationToken ct);

    Task<IReadOnlyDictionary<string, string>> MapForAsync(
        string conversationId, CancellationToken ct);
}

Four properties are not optional.

Scoped. Every operation takes a conversation or request identifier. A vault keyed only by token is a cross-tenant data leak waiting for a token collision, and PERSON_1 collides immediately because every conversation has one.

Encrypted at rest, with the key held elsewhere. The vault holds the mapping from token to real person, which makes it the most sensitive store you own. Chapter 14 covers this; the short version is that the service holding the ciphertext should not hold the key.

Short-lived. Set a TTL that matches the conversation lifetime and let it expire. There is no business reason to retain a de-identification map for ninety days, and every day it exists is a day it can be stolen.

Unreachable from the model path. Nothing the model outputs should be able to cause a vault lookup outside the current request's scope. This is the one that gets built wrong. A helpful "resolve any token in this text" endpoint, called with model-influenced input, is an oracle that will dump your mapping to anyone who can make the model emit token names.

the-leak-you-cant-see
blast-radius
what-counts-as-pii
the-five-doors
the-accuracy-reckoning
the-hybrid-that-does-not-work
deterministic-detection
npi-in-c
the-three-way-choice
calling-the-analyzer-from-c
measuring-your-own-demo-gap
choosing-the-operating-point
the-ladder-of-safeguards
pseudonymisation
the-round-trip
restoring-safely
when-masking-breaks-the-task
plausibility-hazard
the-architecture-that-holds
the-reference-architecture
dont-send-it-at-all
structure-beats-prose
the-gateway
failure-is-a-policy-decision
the-sidecar-you-can-trust
egress-deny-it-at-the-network
rag-and-agents
de-identify-before-you-embed
dual-model-separation
the-boring-controls
evidence-and-the-first-thirty-days
week-two-the-chokepoint-and-the-fast-layer
entity-catalogue-and-c-validators
mbi-positional-rules
tooling-at-a-glance
azure-ai-language-pii-in-detail
container-trust-checklist
sources
azure-ai-language
provider-retention

Download the full PDF for free?

Free download — no account required

Get the PDF
Get the PDF
Related Chapters
Free Download
Get the full PDF
All pages, including all code examples, diagrams, and the appendix reference card.
No spam. Unsubscribe at any time.
Your email won't be shared.
Oops! There's a problem with your request. We're working on fixing it. Please try again later.