A Propagated Failure Is a Typed Result, Not a Message
In most systems, results get a schema and failures get a sentence. The success path is versioned, validated, and reviewed; the failure path is a string someone wrote while debugging something else, and it survives untouched into production because nothing that runs regularly depends on its shape. That asymmetry is tolerable when the only consumer of a failure is a human reading a log after the fact. It stops being tolerable the moment something automated has to make a decision from it.
In an agent system, something always does. When a component fails and the failure crosses a boundary, the receiver is a coordinator that must now choose between retrying, routing elsewhere, accepting what was finished, or stopping. That choice is a program’s choice, and increasingly it is a model’s choice. Both of them are consuming whatever you emitted as an interface, whether you designed it as one or not. So design it as one. What crosses a failure boundary is a typed result with a contract, and the discipline you would apply to any other payload in the system applies here with more force, not less.
The receiver acts on the failure, so the failure has to be actionable
The reason free text fails as an error interface is not that it is imprecise. Often it is quite precise. It fails because precision in prose is not addressable. A coordinator that receives “upstream lookup failed after 3 attempts, giving up” has been told everything it needs and can act on none of it, because acting requires matching that sentence against a set of known situations, and the only way to match a sentence is to parse it. String matching on error text is a coupling nobody declares and a routine rewording breaks. What makes it dangerous is that nothing fails when it does. The failure mode of an untyped error contract is that recovery degrades into pass-through, and pass-through looks exactly like a system that has decided not to recover.
The second consumer is newer and more dangerous. When the receiver is a model deciding how to proceed, prose does not fail to parse. It parses too well. A model handed “giving up” will form a judgment about permanence that the emitting component never made and may not be entitled to make. Hand it something ambiguous and it will not stall the way a parser would; it will resolve the ambiguity in whatever direction the surrounding context leans, and continue with full fluency. This is the specific hazard of putting a reasoning component downstream of an unstructured failure: the ambiguity does not surface as an error, it surfaces as a confident and slightly wrong plan. A typed field constrains that. A model asked to branch on a disposition drawn from a closed set is doing lookup. A model asked to infer disposition from a sentence is doing interpretation, and interpretation is exactly the work you were trying to make deterministic.
None of this argues for making failures unreadable. The envelope should carry a human-legible description, because operators exist and incident review is real work. The argument is about which field carries the decision. The prose is for the person. The typed fields are for the machine. When the only place a decision is recoverable from is the prose, you have built a system whose recovery logic is a text-processing problem.
What the envelope has to carry
Start from the receiver’s decision, not from the failing component’s internal state. Everything in the envelope earns its place by changing what the receiver does; anything that does not is noise wearing the costume of diligence.
The load-bearing field is the error type: a category drawn from a closed vocabulary, meaningful to a switch statement rather than to a reader. The temptation is to let this vocabulary grow out of the failing component’s internal taxonomy, one type per distinct thing that can go wrong inside it. That produces a vocabulary the caller cannot use, because the caller does not have a distinct response for every distinct internal cause. The vocabulary belongs to the caller’s decision space. A type justifies its existence only if some receiver branches differently on it, and if two types always lead to the same action, you have one type and a description field. This is an uncomfortable discipline for the component author, who knows the difference between the four ways the lookup can fail and wants that difference recorded. Record it in the description. Do not put it in the vocabulary the caller has to switch on.
Alongside the type sits identity and context: which component, which task or work item, which run, and whatever the receiver would otherwise have to go fetch in order to know what the situation was. The reconstruction requirement is the part that gets underestimated. A receiver that has to issue three lookups to understand an error is a receiver that will not do it, and a model in that position will fill the gap by inference rather than by lookup. Context in the envelope is not a convenience for debugging. It is what keeps the receiver’s next decision grounded in what actually happened.
Then there is what has already been attempted. A component that has exhausted its own local retries and one that failed on first contact are different situations for the receiver, and the difference is invisible unless it is stated. Without it, the coordinator’s own retry is either a duplicate of work already done or an unrepeated first attempt, and it cannot tell which. This field is also the honest place to record local recovery that eventually succeeded, which matters because the pattern is diagnostic even when the individual event is not. A component that recovers locally on most of its calls is reporting a degraded dependency that no single response contains.
Finally, some envelopes carry a recovery hint, the failing component’s own read on what would help. It earns its place, with one structural caveat that bites harder when the receiver is automated than when it is a person. The component can see its own situation and not the plan, so a coordinator that treats hints as directives has handed its planning authority to whichever component happened to fail most recently, and it will do that silently and every time. Keep the hint in a field whose name makes its status obvious, and keep the branch logic on the type.
Severity is a name for a policy you have already written
Severity levels are the most commonly copied and least commonly designed part of an error contract. A team adopts three or four of them, and each one means roughly what its English name suggests, which is to say nothing enforceable. The label does no work. The receiver still decides case by case, and the levels become a sorting aid for log dashboards rather than a control signal.
A severity level means exactly one thing: the policy the receiver executes when it sees that level. Recording and continuing is a policy. Attempting an alternate path is a policy. Halting dependent work and surfacing to an operator is a policy. Those are meaningfully different behaviors, so those are meaningfully different levels. The test is mechanical and worth applying literally: if two levels map to the same receiver behavior, they are one level. If a level maps to no defined behavior, delete it, because in practice it maps to whatever the receiver improvises, and improvised recovery is the thing the contract exists to eliminate.
There is a subtler point underneath, and it constrains who is allowed to fill the field. Severity is not an intrinsic property of the failure. It is a claim about the consequences for a plan, and the plan lives at the receiver. The same failed lookup is an annotation on a run that can proceed without it and a hard stop on a run whose next step depends on the result. A component that does not know which run it is inside cannot know which of those is true. So the field a component should be filling is not really an assessment of importance. It is an assessment of local resolvability: whether the component has any remaining path to a correct result on its own. That is a fact the component genuinely owns. Mapping that fact onto a disposition for the plan is the receiver’s job, and a contract that lets leaves declare things fatal has handed plan-level authority to components that cannot see the plan. What comes up is the fact and its local finality. What the receiver does with it is policy.
Progress is part of what failed
A failure partway through a multi-step task is not one situation, it is two, and the type alone cannot separate them: nothing may have happened yet, or the earlier steps may have committed effects that a retry would double. The envelope has to say which.
The usual instinct is a counter: a field saying how many steps completed. This is better than nothing and worse than it looks, because a step index is internal vocabulary. It means something to the component’s author and nothing to the receiver, who has no reliable model of what the numbered steps refer to, and less than nothing to a receiver written against a version of the component where they were numbered differently. A step count creates the appearance of resumability while making the receiver responsible for a mapping it does not have.
What the receiver actually needs is the set of effects that are already committed, described in terms both sides share, with the identifiers to act on them. A record was written and here is its key. A message was enqueued and here is its identifier. A downstream call was accepted and here is what it returned. Stated that way, resumption is a decision the receiver can actually make: it can skip what exists, it can hand the remainder to a different component, it can undo what is undoable, or it can accept the partial result and adjust the plan around the gap. Stated as a number, it can only guess. The general form of the rule is that internal progress is not shareable and external effects are, and the envelope should be built entirely out of the second kind.
A retry is only safe if the operation was named before it ran
Idempotency is where an error contract stops being a documentation exercise and starts constraining the architecture, because the receiver’s most natural response to a propagated failure is to try again, and trying again is only safe if the work can recognize itself.
The mechanism is well understood and the constraint on it is not. A key identifies an operation and is checked before the effect is committed, so a second arrival with the same key returns the first result rather than producing a second effect. What that requires is a key minted by whoever owns the intent, before the attempt, and carried down through every retry, because a retried step in an agent system is a regenerated call rather than a resend: same intent, different bytes, and any identity computed from the arguments is a fresh one.
The interesting part is what this forces into the open. If a key names an intent, someone has to say what counts as the same intent, and that is not a question the runtime can answer. Two attempts to send the notification are one intent. Two deliberate sends are two. The difference is not visible in the call, because it is not a property of the call; it is a fact about what the plan meant, and it exists only if a person decided it. Sameness of intent has to be settled where the intent is created and written into the contract, which means an error contract that takes retries seriously ends up constraining what a step in a plan is allowed to be. That is a larger claim than it first appears, and it is the reason idempotency is architecture rather than a field.
The cost of skipping this is asymmetric in a way worth being blunt about. Systems without idempotency do not fail when they retry. They succeed, twice, and the duplicate surfaces later as a reconciliation problem that nobody traces back to an error path that appeared to work correctly.
The error contract is the interface you exercise least and depend on most
Everything above has a cost, and the honest version of this argument names it. A closed type vocabulary is a versioning liability: adding a type is a change every receiver may need to handle, and receivers that silently ignore unknown types will silently ignore the new failure. Effect-level progress reporting means components have to know what they committed, which is a real constraint on how they are written. Intent-scoped idempotency pushes identity generation upward and forces decisions that were previously left implicit. For a system of two components and one hop, this apparatus is heavier than the problem, and building it there is a way of feeling rigorous rather than being rigorous. The contract earns itself at depth, where failures pass through layers, and under automation, where nobody is reading the message.
Depth is also what turns the envelope from a message into a record, and that is worth building for on its own. When each layer adds what it saw and what it did rather than replacing what it received, the chain becomes evidence: an operator can trace a single identifier back to the origin and read what every layer in between decided along the way. The value teams notice second and come to rely on most is not the origin, which they would have found eventually. It is the middle. A coordinator that quietly downgraded a failure before passing it on leaves no mark in a chain that gets rewritten at each hop, and an obvious one in a chain that accumulates.
Two disciplines make the difference between a contract and a document describing one. The first is enforcement: validate the envelope at the boundary the way you validate any other payload, and reject a malformed one loudly, because an error contract decays faster than any other interface in the system. It is exercised least in normal operation and least covered by tests, so drift accumulates in it undetected until the day it is the only thing standing between you and an unrecoverable incident. The second is authorship. Construct the envelope in code at the boundary, from what the component actually knows, and do not ask a model to write one about its own failure. The temptation is real, because the tooling makes it trivial and the output looks right every time: constrained decoding will reliably get you a well-formed object. It will not get you a true one, and a well-formed envelope with an invented effect list is worse than no envelope at all, because a receiver has no way to tell it from a real one and every reason to act on it. Let the code around the model state what was attempted and what was committed, which are facts it holds directly and does not have to recall. Let the model contribute only to the field that is allowed to be wrong, which is the description a human will read.
The through line is a reversal of the usual instinct. Failure information feels like exhaust, a byproduct to be captured cheaply and inspected later. In a system that recovers on its own, it is not exhaust. It is a return value on the path the system takes when things are going badly, which is the path where correctness matters most and attention is scarcest. The component that failed has one job in that moment, and it is not to decide what should happen next. It is to state, in terms the receiver can act on without interpretation, what it tried, what is now true in the world, and whether it has anything left to try. The receiver decides. That division of labor is the contract, and it holds only if what crosses the boundary is typed, complete, and honest enough to be trusted by something that will not read it twice.
