Home

/

Keep PII Out of Your LLM

/

Don't Send It At All

Don't Send It At All

Chapter 12
Part IV
3
min read
The same summarisation task with a fully serialised ticket carrying seven fields of personal data, beside a four-field projection carrying none

Why the whole object gets sent

Nobody decides to send a postal address to a language model. It arrives like this:

var prompt = $"Summarise this support ticket:\n{JsonSerializer.Serialize(ticket)}";

One line. It works. It took eight seconds to write. And ticket has a Customer navigation property, which has an Address, and Customer has a PaymentMethods collection that Entity Framework happened to load because something earlier in the request touched it.

The alternative looked like eleven lines and a new type, so nobody wrote it. That is the entire mechanism. Not negligence, not ignorance, just the relative friction of two options at the moment someone was trying to finish a feature.

The fix has to be cheaper than the leak, and fortunately it is.

The projection

public sealed record TicketForSummary(
    string Subject,
    string Body,
    string Category,
    int AgeInDays);

public static TicketForSummary ForSummary(this Ticket t) => new(
    t.Subject,
    t.Body,
    t.Category,
    (int)(DateTime.UtcNow - t.CreatedAt).TotalDays);
var prompt = $"Summarise this support ticket:\n{JsonSerializer.Serialize(ticket.ForSummary())}";

That is the control. A record, an extension method, and a call site that looks almost identical to the one it replaces.

What it bought you: the customer's name, email, phone, address, account number and payment methods are now structurally incapable of reaching the model from this code path. Not filtered, not detected, not masked. Absent. No detector runs, so no detector can miss them. No token is created, so no restore can fail. The provider retains nothing, because nothing was sent.

Compare that to the alternative you were considering, which was a detector averaging 0.48 F1 on cross-domain text, called over a network, with a failure mode you have to design for.

Don't detect what you can refuse to send.

Make it the type system's job

An extension method is a convention, and conventions decay. The next developer writes a new endpoint and serialises the entity again, because the entity is right there.

Make the compiler enforce it.

// Marker for types explicitly reviewed and approved for prompt inclusion.
public interface IPromptSafe;

public sealed record TicketForSummary(...) : IPromptSafe;

public sealed class PromptBuilder
{
    public string Build<T>(string instruction, T payload) where T : IPromptSafe =>
        $"{instruction}\n{JsonSerializer.Serialize(payload)}";
}

Now Build(instruction, ticket) does not compile. The developer has to define a projection and think, for about thirty seconds, about which fields belong in it. Thirty seconds at the right moment is worth more than any amount of runtime filtering.

This is the pattern's real value. It moves the decision from "did anyone remember to redact" to "you cannot express the unsafe version", and it does so without a runtime dependency, a container, or a latency budget.

Out of band

The second half of minimisation. Sometimes the model needs to refer to a person without needing to know who they are.

The instinct is to put the identifier in the prompt so that the answer can reference it. You do not need to.

// The model works on a reference. Your code resolves it.
var prompt = $"""
    Draft a reply to the customer for ticket {ticket.Reference}.
    Address them as {{CUSTOMER_NAME}}.
    Issue: {ticket.Body}
    """;

var draft = await _model.CompleteAsync(prompt, ct);

// Resolution happens here, on your side, after the model is done.
var reply = draft.Replace("{{CUSTOMER_NAME}}", customer.FullName);

The model composes a letter with a slot in it. Your code fills the slot. The name never enters the prompt, never enters the provider's logs, never enters your prompt cache, and cannot be recovered from anything the model touched.

This generalises well. Any value the model needs to place rather than reason about can be a slot: names, account numbers, dates of appointments, amounts, links. Values the model needs to reason about cannot, and Chapter 10 is about telling the difference.

A caution: the slot marker must be something the model reproduces verbatim and will not helpfully expand. Double-brace tokens work because they are common in templating and models leave them alone. If the model starts filling in slots itself, tighten the instruction and validate the output.

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.