ADR-191: A cancelled tool call is not a failed one
- Status
-
Accepted
- Date
-
2026-09-06
- Extends
-
ADR-190 (cancellation crosses the transport boundary as a signal), ADR-182 (a tool result is transformed, never rebuilt)
- Authors
-
Netresearch DTT GmbH
Context
ADR-190 made a cancelled run stop the MCP call it has on the
wire. The transport raises its own exception for that, and
Mcp returned it the way it returns every transport fault —
as Tool.
A tool result carried one boolean, and that boolean is read all the way to the
screen: Run writes it as
Run, the step's payload persists it,
Run maps it to failed or ok, and the
runs module renders that under Outcome. So an operator who cancelled a run saw
Failed next to a server that had answered nothing wrong, and anything counted
from those rows counted their own cancel as a fault.
Mcp promises that "a server that is flaky is visible without
reading transcripts". With cancellations landing in the same bucket, that was no
longer true.
Decision
-
A tool result states its outcome, and the boolean stays.
Toolhas three cases —Outcome OK,FAILED,CANCELLED— andToolcarries one.Result Toolis unchanged and remains true for both non-OK cases, so every consumer that reads it keeps the meaning it had; the outcome says WHICH of the two.Result::$is Error Not a second boolean. Two booleans encoding one tri-state is the shape ADR-187 rejected for the write target and its kind, for the same reason: it makes "cancelled but not an error" representable, and nothing would ever produce it.
Toolis fail-closed exactly likeResult:: cancelled () self::— no artifacts, no write target — because a call that was cut off has no more claim to either than a failed one.error () - The outcome travels through the bounding transformation.
Toolrebuilds an error result from almost nothing, since a failed call may keep neither artifacts nor a write target. The outcome is the exception, and it is the member that most needs to survive: every tool result in a run passes through that method, so rebuilding it asResult:: with Bounded Channels () FAILEDwould relabel every cancelled call before it reached the audit row. ADR-182 names three values already lost to exactly that shape. - One string travels, end to end.
Tool's value is whatOutcome:: CANCELLED Runwrites, whatStep:: to Array () Runreturns, whatTimeline Factory:: step Outcome () Runholds, and what the template turns into the keyTimeline Entry:: OUTCOME_ CANCELLED runs.. Renaming one side alone renders an empty cell and nothing else would notice, so the equality is asserted rather than assumed.detail. outcome. cancelled - A step cannot disagree with itself.
Runrefuses aStep toolthat contradicts itsIs Error tool, and refuses an outcome on a step that is not a tool step. Refused in the value object rather than at each writer, because that is the object which serialises the pair: one definition of the invariant instead of one per entry point.Outcome - A row written before this keeps the outcome it had.
tooldecides WHETHER a step states an outcome — it is the field every tool step has ever carried — andIs Error tooldecides WHICH. Reading the boolean first is what makes older rows render as they always did instead of losing their outcome to a field they never held.Outcome - The transport says which kind of exception it raised.
McpisTransport Exception final, so there is no subclass to catch; it carries a flag set only byself::, andfor Cancelled Call () self::reads it. A code comparison at the call site would work too, but a code is a value anyone can copy, and then two places would decide what "cancelled" means.is Cancellation ()
Consequences
ToolisOutcome @apiand recorded on the frozen surface, as the closure rule requires for a type an@apisignature mentions.ToolgainsResult cancelledand() $outcome. Nothing on the surface changes shape:Runkeeps its signature and derives the outcome from the boolean it already took, and the typedTrace:: record Tool Execution () Runreads it off the result. Both build the step through one private method, whereTrace:: record Tool Result () toolis DERIVED from the outcome rather than passed beside it, so the pair cannot disagree there at all.Is Error - The runs module shows cancelled as its own outcome, in English and German.
- What is NOT decided here: anything about the remote write. Whether a torn-down call mutated something is not knowable from this side — see ADR-190, decision 4, which this record does not revisit.
Tool, which the loop also builds from a result, is left alone: it carries the boolean, is not on the frozen surface, and nothing in the inspector chain reads it. A second place stating the outcome would be a second place to keep in step.Invocation