Skip to content

15 min read

Redacted prompt logs and provenance

On this page

An incident report has a section headed Reproduction, and for a prompt-rendering failure it is usually the section that stays empty. The service recorded what it was built to record: a request id, the name of the template, the model identifier, and the response text. None of that identifies the message that was actually sent, because the message was that template filled with a dozen or so values drawn from several systems at one particular moment.

The obvious repair — log the rendered prompt — is right about the diagnosis and creates a second problem larger than the first. This chapter is about the record that answers the diagnostic question without becoming the longest-retained, widest-access copy of your customer data.

What incident reports can conclude from a template name

Consider a concrete failure. A customer posts a screenshot: the billing assistant, in her session, describing a Scale plan renewing on 4 April for $2,400, when she is on Growth and pays $79 a month. The log record holds request id req_7c1f9a, template billing_assistant, model claude-sonnet-4-5-20250929, and the full response text. Replaying her question through the current code produces a correct answer about Growth, five times in a row.

Three explanations fit every byte of that record equally well. Retrieval may have returned a document belonging to another customer, because the index filter on customer_id is applied in application code and there was a deploy that morning. The render context may have been assembled from a stale account object, since the service caches account records for 60 seconds and had recently been converted to async handlers, where a shared object reused across tasks is a live possibility. Or the model may have invented a plausible plan and price with no wrong data anywhere in its input. These have different owners, different fixes, and different disclosure consequences, and the log cannot rank them.

Three explanations against one observation is a ratio worth pausing on. All three predict the same observable — a response naming a plan the customer is not on — so the thing the service wrote down is consistent with every one of them and discriminates between none. What would discriminate is the input: a retrieval failure and a stale account object put different wrong values into different slots, and a fabrication puts a wrong value into no slot at all. The record contains no slots. Logs assembled from whatever was already in hand at the call site tend to have exactly this shape, because the response and the identifiers are one variable away and the substitutions are several frames up.

The reason is worth being precise about, because the record looks thorough. A template name identifies a shape. The message that reached the model was that shape filled with fourteen values drawn from six systems at a particular moment, and every interesting failure in a prompt-rendering pipeline is a failure of which value went into which slot. Recording the template name is equivalent to recording that a SQL error came from orders_report.sql without the bound parameters; it tells you which file to open and leaves you guessing about the row.

The response text has the same problem in a more seductive form, since it is the only thing in the record that contains any specifics at all. It contains the string "Scale", which proves the word reached the output and says nothing about whether it entered through retrieval, through the account object, or through the decoder. Reasoning backwards from an output to an input is exactly the inference the log was supposed to make unnecessary.

The obvious fix, priced honestly

Log the whole rendered prompt. It is how a debugging improvement becomes the next incident, and the numbers are worth having in front of you before the argument with the compliance lead rather than during it.

At about 340,000 assistant requests a day and an average rendered prompt of 4.1 kB, that is 1.4 GB per day of prompt text. A log pipeline shipping to a vendor with 400-day retention, read by 60 engineers because that is how on-call works, then holds 560 GB of it. Those prompts contain the customer's legal name, plan, billing address region, and the text of their last three tickets. Set beside the source systems — a customer database with row-level access control, field-level encryption on two columns, and a 30-day service-level agreement for honouring erasure requests, the demands for deletion that data-protection law lets a customer make — the log is now the longest-retained, widest-access, least-controlled copy of the same data. It is also unaddressable: when an erasure request arrives, there is no way to find that customer's rows in 560 GB of unstructured prompt text, so the request cannot be honoured at all.

Both multiplications are worth doing in the open, because an argument with a compliance lead is won or lost on whether the figures were derived or asserted. Three hundred and forty thousand requests a day, multiplied by a rendered prompt of about four kilobytes, is a little under a gigabyte and a half — that is the daily quantity. Multiplying a daily quantity by the retention period is the right second step, because a log store holds every day still inside its window simultaneously rather than one at a time, and four hundred days of a gigabyte and a bit is the five hundred and sixty. Neither operation is clever. What makes them worth writing down is that the second is the one teams skip: a gigabyte a day sounds like nothing, and the number that exists in the vendor's account is the product of the two.

A compliance lead saying prompts must never be written to a log system is defending something real. The productive response is to agree with the premise and change what gets written, because the engineer's requirement is to see the structure and the substitutions, and the structure and the substitutions are not the same thing as the values.

Redaction at render time, because that is where the labels are

Redacting at log time means running patterns over a 4 kB blob and hoping. It works for shapes — email addresses, card numbers, anything with a checksum — and it fails completely on the values that cause failures like the one above, since Scale is a word and Growth is a word and no regex distinguishes a plan name from prose. At render time the situation is entirely different: each value is still a separate object with a name, a source, and a classification, exactly as chapter 3's typed slots require. So the renderer emits two strings from the same template.

python
REDACT = {"confidential", "customer_text"}

def render_pair(template: str, slots: list[Slot]) -> tuple[str, str, list[dict]]:
    real, safe = {}, {}
    for s in slots:
        real[s.name] = escape_for(s)
        safe[s.name] = (
            f"<redacted:{s.name} class={s.klass} len={len(s.value)}>"
            if s.klass in REDACT else escape_for(s)
        )
    provenance = [
        {"name": s.name, "path": s.path, "class": s.klass, "source": s.source,
         "record_id": s.record_id, "len": len(s.value),
         "sha256_12": sha256(s.value.encode()).hexdigest()[:12]}
        for s in slots
    ]
    return template.format(**real), template.format(**safe), provenance

The skeleton goes through the same template as the real message, so every newline, heading, fence, and tag is byte-identical to what the model received, and a structural bug — a section rendered twice, a delimiter that came out wrong, a tool block in the wrong order — is visible in it. What is missing is precisely the customer's data, replaced by a placeholder that preserves the field name and the length.

That difference is the concatenation argument from chapter 1 arriving a second time in a different costume. A rendered prompt is a flat sequence of characters in which nothing records where any of them came from, so a redactor reading it has to infer a value's class from its shape — and a plan name has no shape. A slot list is the same information one step before it was flattened: each value interpolated into the message still has its own name, its own source and its own class, because nothing has yet joined it to anything. Redaction is a classification problem, classification needs provenance, and provenance survives right up to the moment of concatenation and not one character past it. That is why both strings come out of the same call rather than the second being reconstructed downstream. The only point where either is cheap is the point where the values are still separate objects.

The provenance list is the half that settles a question like the one above:

json
{
  "request_id": "req_7c1f9a",
  "ts": "2026-03-10T14:02:07.418Z",
  "session_customer": "cus_41907de5c1",
  "template": {"name": "billing_assistant", "revision": "unversioned"},
  "model": "claude-sonnet-4-5-20250929",
  "slots": [
    {"name": "plan_tier", "path": "trusted", "class": "confidential",
     "source": "billing.subscriptions.plan_name",
     "record_id": "cus_88213bf40a", "len": 5, "sha256_12": "9a1c04f7b3de"},
    {"name": "question", "path": "untrusted", "class": "customer_text",
     "source": "request.body.question",
     "record_id": null, "len": 63, "sha256_12": "41b8e07c2d55"},
    {"name": "article_body", "path": "untrusted", "class": "public",
     "source": "articles.body",
     "record_id": "art_5512", "len": 1840, "sha256_12": "c0e4aa93b118"}
  ]
}

The plan_tier slot names cus_88213bf40a as its source record while the session belongs to cus_41907de5c1. Two of the three hypotheses die on that line, and the third — the model inventing a plan — dies with them, because a wrong customer's real record was genuinely in the prompt. The remaining question is how a stale account object reached the render context, which is an ordinary concurrency bug that an engineer can chase with ordinary tools.

Question the engineer must answerField that answers itBytesWhat it exposes
Which template shape was usedtemplate~40Nothing
Did the structure render correctlyprompt_skeleton~900Your own template text
Did a value come from the wrong recordslots[].record_id20 per slotA pseudonymous identifier
Are two requests substituting the same valueslots[].sha256_1212 per slotLittle, with a caveat below
Did an untrusted value take the untrusted pathslots[].path9 per slotNothing
Was a value unexpectedly long or emptyslots[].len4 per slotLength only
What the customer actually typedNot stored0—

The hash column carries the caveat that matters. A 12-hex-character prefix of SHA-256 over a field with five possible values is trivially reversible, so hashes here are a comparison aid — did these two requests substitute the same string — and never a protection. The protection is the classification, the placeholder, and the access control on the store.

A reader who takes the hash for protection is making a reasonable mistake, and it is worth naming as one. A hash is a one-way function, which is a true description of the function and a misleading description of the situation, because nobody attacking it has to invert anything. A plan tier is one of a handful of strings anyone can list, so recovering a plan tier from its digest means hashing those few candidates and comparing the results. The same argument covers every low-entropy field in a prompt: a country code, a boolean written as a word, an identifier drawn from a range you can enumerate. A hash protects a value drawn from a space too large to walk through, and almost nothing anybody is tempted to hash in a prompt record is drawn from one.

The skeleton has to be emitted from the bytes that were sent

A skeleton is only evidence if it corresponds to the message the provider received, and the gap between those two things is where the second failure of this kind hides. A client that assembles the rendered prompt and then trims it — dropping articles from the middle of the evidence block when the prompt plus the retrieved documents exceed the context budget, and truncating ticket_history to the last 8,000 characters on long conversations — is doing both of those after render_pair returned. For six weeks that meant a skeleton showing three retrieved articles where the model had been sent one, and a report about a missing policy chased through retrieval for two days before anyone read the transport code.

The cheap correction is to emit the record from the same object that gets serialised into the request body, and to store a hash of that body so the correspondence is checkable rather than assumed.

python
body = build_request(messages=messages, model=MODEL, max_tokens=1024)
record["prompt_sha256"] = sha256(canonical_json(body).encode()).hexdigest()
record["prompt_bytes"] = len(canonical_json(body))
record["skeleton"] = skeleton_from(body, slots)   # same object, redacted
resp = client.messages.create(**body)
record["response_id"] = resp.id

Recording prompt_bytes beside the skeleton's own length gives a one-line check that they agree, and a mismatch is a bug in the logging path rather than in the assistant. The hash is also what lets an engineer say that two requests were identical, or that a replay reproduces the same input, which is the claim the Reproduction section is actually making.

Three links make that record evidence rather than a description of something nearby, and each is a place a pipeline usually breaks. The skeleton is derived from the same object that gets serialised, so anything the transport layer does — dropping articles, truncating ticket histories — has already happened by the time either string is produced. The hash is taken over the serialised bytes, so two records claiming to be the same input can be compared without either of them holding the input. And the byte count sits beside the skeleton's own length, so the claim that the two correspond is checked by a machine on every request rather than asserted once in a design document. Break any single link and the record is still present, still readable, and no longer about the message the provider received.

Flowchart: Typed slots → render_pair; render_pair → Real prompt; render_pair → Skeleton + provenance; Real prompt → Truncate to budget; Truncate to budget → Provider; Truncate to budget → sha256 of body; Skeleton + provenance → Diagnostic store 14 days, audited; sha256 of body → Diagnostic store 14 days, audited; Diagnostic store 14 days, audited → Engineer; Engineer → Re-fetch by record_id own credentials; Re-fetch by record_id own credentials → Source systems.

The two paths out of the renderer never rejoin, which is the property the compliance argument rests on. Customer values travel to the provider and nowhere else; names, classifications, identifiers and lengths travel to the log; and the only route from the log back to a value goes through the source system under an engineer's own authorisation.

From one incident to a standing check

Once provenance is a field rather than a narrative, the query that answers one report can run on a schedule instead of at 14:02.

sql
select r.request_id, r.ts, r.session_customer, s.name, s.record_id
from prompt_records r, jsonb_to_recordset(r.slots)
     as s(name text, record_id text, path text)
where s.record_id is not null
  and s.record_id like 'cus_%'
  and s.record_id <> r.session_customer
  and r.ts > now() - interval '1 hour';

Reading that query is a two-step job. jsonb_to_recordset expands the slots array of each record into one row per slot, so a request with fourteen slots becomes fourteen rows sharing a request id; without that expansion the record ids are buried inside a JSON document and cannot be compared to anything. The where clause then keeps the rows where a slot's source record names a customer other than the session's. That comparison is the entire check, and it is available only because both halves of it were promoted to fields — had record_id stayed inside the prompt text, the same question would be a substring search across blobs, which is the query nobody writes.

Every row is a prompt that contained another customer's record, and the check does not depend on anyone noticing or complaining. Two further checks cost nothing on the same table: a slot on the trusted path whose source is a user-writable column, which catches the chapter 3 misclassification returning after a refactor, and a request whose article_body length exceeds a sane ceiling, which is how a 40,000-character document announces itself before it evicts the policy.

Retention that a compliance lead can sign

The skeleton and provenance record together run about 1.1 kB against 4.1 kB for the raw prompt, so 340,000 requests a day produce 375 MB. Held for 14 days rather than 400, that is 5.2 GB retained instead of 560 GB, and the store is structured: session_customer and record_id are first-class fields, so an erasure request is a delete by index rather than a full-text search through blobs.

Reconstructing a real prompt, when an engineer genuinely needs one, happens by re-fetching each recorded record_id from its source system under the engineer's own credentials, through the same authorisation the customer console uses, with the access recorded. Two properties fall out of that design without extra work. The reconstruction respects deletion, because a record erased last week cannot be re-fetched and the prompt simply cannot be rebuilt, which is the correct behaviour rather than an inconvenience. And access to customer data goes through the system that was built to control access to customer data, instead of through a log index with sixty readers.

There is a narrow case for capturing raw text: a customer reports a bad answer where every value was correct and the model's reasoning was the problem, and no skeleton can settle that. A per-session debug flag that a support engineer sets with the customer's explicit agreement, a 24-hour TTL — a time to live, after which the store deletes the captured text by itself — and an entry in the same audit trail handles it. It runs a few dozen times a month rather than 340,000 times a day, which is the difference between an exception and an architecture.

Reproducing a prompt through that flag is the one path in this design that produces an unredacted copy of anything, and keeping it a path rather than a default is what keeps the compliance argument short. There is a single route to raw customer text, it requires a named engineer, a consenting customer and a clock, and every use of it leaves a row somebody can read.

Not a trace, and not an eval

The skeleton is not a trace. It says what was sent and does not say how long retrieval took, which index shard answered, or how many tokens the provider billed, and those belong in spans, the timed segments a distributed trace is assembled from, alongside the request id rather than in the prompt record. Nor is it an eval: one captured request tells you what happened once, and a systematic quality question needs a set of cases scored against a rubric.

What the pair does give you is the two halves of a reproduction that the template name only pretended to cover. The skeleton is the structure, byte-identical to what the model saw, so a delimiter that came out wrong or a section rendered twice is visible without anyone guessing. The provenance list is the substitutions, named by source record rather than by value, so the question of which system supplied a wrong string is answered by reading a line rather than by inferring backwards from an output. Both are cheap enough that the retention argument disappears, which is the only reason they get built.

The template.revision field in that JSON also says unversioned, which is not a placeholder for something the pipeline forgot to fill in. Where forty prompts live as module-level strings, edited in place, the honest value for that field is the name of the module. A record that can pinpoint which source supplied a value, but cannot say which wording of the template was in production at 14:02, has solved half of a problem that gets much worse when the question is not about one request but about two eval runs a week apart.

Prompt templating and escaping user input — read the current version at https://daviddominguez.dev/learn/en/prompt-templating-and-escaping-user-input/incident-reports

Back to top