Skip to content

8 min read

Log the payload's shape when you may not log its contents

On this page

The customer had said, twice, that under no circumstances should anyone contact her employer about the outstanding balance. Three days later a collections letter went to her HR department. The complaint arrived on a Tuesday morning and the investigation took until early evening, five hours of one engineer's time, across a run that had involved four agents: an intake agent, a case classifier, a strategy agent and a correspondence agent.

The logs were not absent. Every agent recorded its full prompt, its full output and every tool call, retained for thirty days. What they did not record was any boundary. Each handoff had been assembled as prose and pasted into the next agent's prompt, so finding where the constraint stopped travelling meant opening four prompts of between 6,000 and 14,000 tokens, reading each one for a paraphrase of a thing the customer had said in her own words, and deciding whether an absence was a real absence or a rewording. Searching for "employer" returned 61 matches across the run, most of them from a retrieved policy document about workplace garnishment. The constraint had survived intake, survived classification in a compressed form — "prefers discreet handling" — and was simply not present in what the strategy agent gave the correspondence agent. Establishing that took five hours because there was nothing to query, only text to read.

The record that would have made it minutes

A boundary is an event, and an event can be emitted. The thing that makes the next investigation a query rather than a reading exercise is a record written at both ends of every handoff, structurally identical across all boundaries so it can be joined, filtered and compared.

json
{
  "event": "handoff.emitted",
  "ts": "2026-02-03T14:22:07.412Z",
  "correlation_id": "case-99120",
  "trace_id": "7d1f4a09c2b34e51",
  "from": "strategy_agent",
  "to": "correspondence_agent",
  "schema_version": "1.1",
  "bytes": 3104,
  "tokens": 731,
  "populated": ["goal", "constraints", "evidence", "owner", "returns_to"],
  "absent_optional": ["open_questions", "narrative", "deadline"],
  "counts": { "constraints": 1, "evidence": 4, "open_questions": 0 },
  "constraint_digests": ["b41e9c07"],
  "constraints_elicited": true,
  "unknown_fields": [],
  "validation": "ok"
}

Nothing in that record is customer data. It says a payload of 731 tokens on schema 1.1 went from the strategy agent to the correspondence agent carrying exactly one constraint, and the corresponding record at the previous boundary says the classifier sent the strategy agent three. That difference is the entire diagnosis, and it is a SELECT rather than an afternoon.

The digests are the part that turns a count into an identification. Hashing a normalised form of each constraint — lowercased, whitespace-collapsed, with a per-tenant salt so that short common strings are not recoverable by enumeration — gives every constraint a stable eight-character identity that follows it across boundaries without revealing a word of it. Laid out across a run, the result reads as a transmission trace.

boundaryschematokensconstraint digestspopulated optionalsvalidation
intake → classifier1.11,940b41e9c07, 2f80ad13, c95b7e44open_questions, narrativeok
classifier → strategy1.11,205b41e9c07, 2f80ad13, c95b7e44narrativeok
strategy → correspondence1.1731b41e9c07—ok

Digest 2f80ad13 is present at two boundaries and gone at the third, and the record names the agent that dropped it and the millisecond it happened. Whoever picks the ticket up does not need to know what 2f80ad13 says in order to know that the strategy agent lost it; they need to know what it says only once they have decided to look, and by then they are looking at one agent's behaviour on one run rather than at four prompts.

A digest that changes rather than disappearing is the other signal worth having, and it is the one the classifier produced here. If the classifier had rewritten "do not contact my employer" into "prefers discreet handling" and passed it along, the count would have stayed at three and the digest would have changed from 2f80ad13 to something else. A changed digest at a boundary where the count is stable is a rewrite, which is a different defect from a drop and has a different fix — a sender that is paraphrasing constraints instead of forwarding them.

Why the obvious answer loses the review

The proposal that gets written first is almost always to log the full payload at every boundary with a ninety-day retention window and role-based access control, on the reasoning that the security concern is about who can read the data rather than about whether it exists. That argument fails, and it deserves to.

Data minimisation is a rule about existence and purpose, not about access. Under Article 5 of the GDPR, the European data protection regulation that governs this for anyone with EU customers, the question asked of every field is whether it is adequate, relevant and limited to what is necessary for the stated purpose, and "an engineer might need it during an investigation" is not a purpose that survives contact with a reviewer when the same investigation is demonstrably possible from digests and counts. The practical consequence is worse than the legal one: a log store that holds every payload becomes the largest copy of customer conversation in the company, sitting in the system with the widest read access, the loosest retention enforcement and the most third-party integrations, and it will be the thing that shows up in an incident. Answering a minimisation objection with an access-control mechanism concedes that the data should be there and then defends the weakest part of the argument.

What makes the shape-only design persuasive in that room is that it is not a compromise. The five-hour investigation did not need to know what the customer said. It needed to know which fields were populated at each boundary, which schema version was in play, and whether a particular item survived each hop, and every one of those is answerable without storing a single word of customer content.

python
from opentelemetry import trace
tracer = trace.get_tracer("agents.handoff")

def emit_handoff(payload: Handoff, *, frm: str, to: str, tenant: str) -> None:
    with tracer.start_as_current_span("handoff") as span:
        span.set_attributes({
            "handoff.from": frm,
            "handoff.to": to,
            "handoff.schema_version": payload.schema_version,
            "handoff.correlation_id": payload.correlation_id,
            "handoff.tokens": count_tokens(payload),
            "handoff.populated": ",".join(populated_paths(payload)),
            "handoff.constraint_count": len(payload.constraints),
            "handoff.constraint_digests": ",".join(
                digest(c.rule, tenant) for c in payload.constraints
            ),
            "handoff.hard_constraint_count": sum(
                1 for c in payload.constraints if c.kind == "hard"
            ),
            "handoff.constraints_elicited": payload.constraints_elicited,
        })

def digest(text: str, tenant: str) -> str:
    norm = " ".join(text.lower().split())
    return hmac.new(SALTS[tenant], norm.encode(), "sha256").hexdigest()[:8]

Putting this on a span — the unit of a distributed trace, one timed operation that records when it started, when it ended and what it was doing, and that can nest inside another — rather than only in a log line buys the timestamps at both ends for free, since the receiver's parse is a child span and the gap between them is the queue time. It also buys the join: a correlation ID, meaning one identifier stamped on every event belonging to the same run, lets you assemble a run, and a trace ID lets you see the run interleaved with the tool calls and model calls that happened inside each agent, which is what tells you whether a 4.2-second gap at a boundary was transport or a retry.

The other half of the instrumentation is the failures, which are cheap to record and almost always under-recorded because they are exceptions and exceptions go to an error tracker rather than to the boundary's own metrics. Every validation failure wants the sender, the receiver, the schema version and the failing field path as labels, so that a step change is visible as a step change on one series rather than as a rise in a general error rate. The same is true of the unknown-field counter from chapter 2 and the version-dispatch rejections: those are not errors in the ordinary sense, they are the boundary telling you that two components have drifted apart, and they belong on the same dashboard as the drops.

question you have at 02:00what answers itcontains customer content
which agent dropped the constraintdigest sequence across boundariesno
was it dropped or rewordeddigest changed vs digest absentno
did the sender even look for constraintsconstraints_elicited flagno
are the two sides on the same schemaschema_version at both ends, unknown-field counterno
is one boundary getting slowerspan duration by from/to pairno
did the payload get truncatedtoken count against the schema's expected rangeno
what did the customer actually saynothing here; requires break-glassyes

Knowing when to stop instrumenting

A boundary is adequately instrumented when three things are true, and they are worth stating as acceptance criteria because instrumentation work otherwise expands until someone gets bored. For any run inside the retention window, you can locate the boundary at which a given field stopped being populated without opening a payload. Every validation failure, unknown field and version rejection is counted with sender, receiver and version as labels, so a regression at one boundary is visible without knowing to look for it. And the boundary events for a run are joinable by correlation ID across every agent, retained at least as long as the window in which customers can complain, which for most consumer products means the complaint window plus a margin rather than the thirty days that logging defaults tend to ship with.

What this does not give you is why. A digest tells you that constraint 2f80ad13 did not survive the strategy agent; it does not tell you whether the agent's prompt discourages carrying constraints, whether the constraint conflicted with another and the agent chose, or whether a token limit truncated the list. Shape localises the defect to one component and one field, and localising is most of the cost of debugging, but the last step is still reading that agent's behaviour on that run.

Shape is also blind to loss that happens inside a populated field. A constraints list with three well-formed entries scores identically whether the entries are the customer's actual limits or three plausible inventions, and a goal field of the right length can be the wrong goal. That is the boundary between instrumentation and evaluation, and the honest position is that shape logging is a diagnostic tool rather than a quality measure; it tells you where information stopped moving and cannot tell you whether the information was right.

For the residual case there is a defensible escape hatch — the break-glass path named in the last row of the table above, meaning access that stays closed by default and has to be deliberately and visibly opened — provided it is designed as an exception rather than as the default. A per-run capture of full payloads, enabled by an explicit action, scoped to one correlation ID, retained for seventy-two hours and recorded in an audit log that names who enabled it, is a proposal a security reviewer will accept, because it is bounded in scope, in time and in who can invoke it. Reaching for it two or three times a quarter is a working system. Reaching for it weekly means the shape record is missing a field, and the right response is to add that field rather than to widen the exception.

With boundaries emitting shape, a class of argument that used to be unwinnable becomes a query. When several teams report that their agents have become unreliable, the counts and digests at each boundary say whether the payloads those agents receive have changed, and that turns out to matter more than it sounds, because agents that share an upstream fail together in a way that looks exactly like failing separately.

Inter-agent communication — read the current version at https://daviddominguez.dev/learn/en/inter-agent-communication/log-the-payload-s-shape-when-you-may-not-log-its-contents

Back to top