Chapter 4 ended on a sentence worth repeating, because everything here follows from it. A language model has no representation of where its input came from. Both your instructions and an attacker's arrive as tokens, both are instruction-shaped, and nothing in the context window carries a label.
So put the label outside the context window.
Provenance is the practice of tagging every value in your system with where it came from, and keeping that tag attached as the value moves. It is not a new idea. Taint tracking has existed in application security for decades, and it is how a great deal of injection-class tooling works. What is new is applying it to a system where the untrusted data does not stay in a value slot, because there are no slots.
Start with the smallest thing that works.
public enum Provenance { Trusted, Tainted }
public readonly record struct Tagged<T>(T Value, Provenance Origin)
{
public static Tagged<T> Trust(T v) => new(v, Provenance.Trusted);
public static Tagged<T> Taint(T v) => new(v, Provenance.Tainted);
public Tagged<TOut> Map<TOut>(Func<T, TOut> f) => new(f(Value), Origin);
}Trusted means the value originated inside your boundary: your system prompt, your configuration, a value an authenticated user typed into a field you control, a row your own code wrote.
Tainted means anything else. A retrieved document, a fetched page, a tool result from a third party, an uploaded file, the body of a ticket.
The temptation at this point is to build a lattice. Five levels of trust, a partial order, rules for combining them. Resist it for now. Teams that start with a rich trust model spend their first month arguing about whether a vetted supplier's API response is level two or level three, and ship nothing. Two labels answer the question the gate actually asks, and you can always refine later against real cases rather than imagined ones.
Values combine. A prompt is assembled from several pieces, a summary is drawn from several documents, an argument is computed from two others. So provenance needs a join rule, and there is only one safe one.
public static Provenance Join(params Provenance[] origins) =>
origins.Any(o => o is Provenance.Tainted)
? Provenance.Tainted
: Provenance.Trusted;Taint wins. Always. One tainted input in a combination of twenty makes the result tainted, and there is no operation in your system that cleans it.
That last clause is the part people push back on, so be clear about it. There is no Sanitise() method in this chapter and there will not be one in this book. Provenance is a claim about origin, not about content. You cannot inspect a string and discover where it came from, and any function that claims to convert tainted to trusted is a classifier, which chapter 4 disposed of.
Here is where teams flinch.
public async Task<Tagged<string>> SummariseAsync(
Tagged<string> document, CancellationToken ct)
{
var response = await _model.GetResponseAsync(Prompt(document.Value), cancellationToken: ct);
// The model read tainted input. Everything it emitted inherits the taint.
return new Tagged<string>(response.Text, document.Origin);
}A model that has read a tainted document produces tainted output. All of it. The summary, the extracted fields, the classification, the confidence score, the model's own commentary about how trustworthy the document seemed.
This feels wrong the first time and it is correct. The model is the most thoroughly compromised component in the system once it has read attacker-controlled text. Treating its output as clean because it passed through something clever is precisely the mistake that makes injection dangerous in the first place.
Follow the implication and you find that taint spreads much faster than anyone expects. An agent that reads one poisoned document has a tainted conversation for the rest of the session, because the model's context now contains that text and every subsequent generation is conditioned on it. Provenance at the value level understates this. Chapter 7 is the structural answer, and it works by keeping the tainted text out of the privileged model entirely rather than by tracking how far it has spread.
Real systems have edges where provenance information is missing. A value arrives from a queue, a cache, a service written by a team that has never heard of any of this.
public static Tagged<T> FromUnknownSource<T>(T value) =>
new(value, Provenance.Tainted);Unknown origin is tainted origin. This is the fail-closed default and it should be the only constructor available at a system boundary. Make the trusted constructor inconvenient to reach, ideally requiring an explicit call at a place a reviewer will notice, because the failure mode here is quiet and permanent: one helper that defaults to trusted, called in one integration, and the guarantee is gone with nothing to show for it.
A useful test during review is to grep for every call to Trust and ask whether a person can explain, in one sentence, why that specific value originated inside the boundary. If the sentence has an "although" in it, the answer is tainted.
Provenance is only useful if it survives to the point where a decision is made. That means the tag travels with the value all the way into the tool call, which in practice means tool signatures change.
// Before
Task<RefundResult> IssueRefundAsync(decimal amount, string orderId);
// After
Task<RefundResult> IssueRefundAsync(Tagged<decimal> amount, Tagged<string> orderId);That is the cost of this chapter, stated plainly, and it is the reason teams skip it. Every tool touched, every signature changed, a couple of days of mechanical work with no visible product change at the end. The refactor is boring and it is the thing that makes chapters 8 and 9 possible, because a capability check on an argument of unknown origin decides nothing.
If the full refactor is not available, the partial version is still worth having: tag the arguments of your irreversible tools only, and leave the rest alone. Three tools instead of thirty, one afternoon, and the gate gets its most important input.
Download the full PDF for free?
Free download — no account required