ADR 0018 — The recorded trajectory, and the agent under test¶
- Status: accepted — the text first, the implementation written against it, the way ADR 0014, ADR 0015, ADR 0016 and ADR 0017 were
- Date: 2026-09-13
- Assumes: ADR 0002 §2 (the
payload stays where it is born, the verdict travels);
ADR 0004 §6 ("the names, not
the arguments") and its Not decided here, which named the condition under
which the arguments arrive;
ADR 0006 §5 (the interval a
movement is judged against);
ADR 0014 §1 (the passenger rule, which
both fields below are checked against), §6 (what a bump costs downstream);
ADR 0015 §1 (a
re-judge without a field errors the check), §4 (no
Disclosurereleases a recorded answer), §6 (the refusals) - Carries:
resumed_at(ADR 0017 §11), pre-vetted there and boarding here - Amends: ADR 0012 §3 — the fact list is closed by that section, and §8 below adds to it with the sentence that justifies it
- Turns into surface:
AGENTS.md(a check that is on the line is not a check that moved),docs/metrics.md,docs/api.md,docs/declarative.md - Touches:
CLAUDE.md's fixed decision 9 — reaffirmed in a second literal case. A tool argument is the end company's data by construction; it is recorded only where it is born and no disclosure releases it. Decision 3 is reaffirmed by §5's mandatory matcher. Nothing in the fixed section is amended
Context¶
ADR 0004 §6 left the arguments of a tool call out of the record, on two grounds it stated plainly: the three providers disagree about what an argument is, and nothing would read them. It also wrote down the condition for reversing itself, and it is worth quoting because this record is that reversal:
The record can gain the field without a schema bump, a migration or a re-promotion — it never enters the run document — so the assertion that needs them may bring them, and should bring its own answer about whether an argument check is exact, a subset, or a predicate.
An agent under gate is that assertion's case. Which tool, with what arguments,
in what order is the behaviour of an agent, and a suite that can only say
it called search then cite is blind to the half of the trajectory that
carries the meaning.
But the argument for recording them is not the one it looks like. "The
example asserts on them, therefore they are verdict material, therefore they
enter the document" does not survive contact with this codebase. Every assertion
here asserts on payload — Contains asserts on the output, PiiAbsent asserts
on the PII — and ADR 0015 §4 exists precisely to rule that the output still
never travels. If asserted on implied verdict material, ADR 0015 would be
incoherent. And the assertion does not need the document at all: it reads the
trajectory live out of Response.metadata, which is not persisted. A working
argument check costs no schema move.
What does cost one is a defect found while scoping this, which is not about arguments and would outlive them:
Replay.__call__ -> Response(output=…, input=…, cost_usd=…, latency_ms=…)
Response.metadata is not reconstructed, because RecordedResponse has no
field it could be reconstructed from. So a replayed ToolsCalled reads an empty
mapping, takes its nobody reported branch, and returns error. Any suite
holding a trajectory assertion cannot be re-judged today; it can only be
re-judged into a row nobody gated on. That is, word for word, the failure
ADR 0015 §1 named when it decided that cost_usd and latency_ms had to ride
the record:
a suite holding a
CostBudgetor aLatencyBudget, re-judged without them, does not fail those checks — it errors them, silently turning a declared gate into a row nobody gated on.
ADR 0015 fixed that for the budgets and missed it for the trajectory, because
budgets arrive as fields on EvaluatorInputs and a trajectory arrives through
metadata. Same defect, one door over, and nothing in the test suite pairs
ToolsCalled with rejudge to notice.
So the ground for the bump is re-judge parity, not the assertion. That ordering decides where the field goes, which is §2.
Decision¶
1. What is recorded¶
RecordedResponse gains a sequence, beside the answer it belongs to:
RecordedResponse.tool_calls: tuple[RecordedToolCall, ...] = ()
RecordedToolCall:
tool: str the name, as the target reported it
arguments: str | None canonical JSON of the arguments
result: str | None what the tool returned, as text
status: Literal["success", "error"] whether the tool itself failed
arguments is canonical JSON by the rule record_output already follows —
canonical(), sorted keys, tight separators — so the same call always produces
the same bytes and a run file stays diffable. None where the target reported a
call without them, which is not the same fact as an empty object and is not
written as one.
result and status are recorded, and they are not padding. A tool that
ran and failed is invisible to a names-only trajectory: the model called the
right tool, the call raised, and the agent answered from nothing. That is a
regression every other assertion in this repository is blind to, and it costs
one enum to see. status is also the one field here that a fake cannot forge
into vacuity — a tool that raises reports error whether or not the model is
real, which is fixed decision 3 holding on the offline path.
Under the existing ceiling, not a new one. MAX_RECORDED_CHARS applies to
the trajectory exactly as it applies to output and input, and over the
ceiling the whole entry is oversize and records neither. Whole or nothing, for
ADR 0015 §3's reason: a clipped argument re-judged produces a score that looks
like every other score, measured on evidence the document does not admit is
partial.
() is a fact and None is not available. The tuple is empty where the
target reported no calls, and a target that says nothing about tools records an
empty tuple too — the distinction nobody reported versus called none lives
on Completion.tools, where ToolsCalled already reads it, and is not
duplicated here. §4 is what carries it across a replay.
2. It rides RecordedResponse, because that is where the boundary already is¶
Not a free-standing field on CaseResult, and not Score.metadata.
RecordedResponse is the one structure in the document already classified as
payload, and every sentence it has earned is the sentence a trajectory needs:
redact() replaces it with RecordedResponse(withheld=True) and keeps only the
count; digline.wire does not know its name; promote_baseline strips it
through without_responses, so the one committed artifact in this product stays
payload-free. A trajectory placed there inherits all of it and adds no
mechanism. There is no new flag, no new disclosure, and no new question to get
wrong at a boundary.
Score.metadata is the wrong home for the mirror-image reason. It is the bag
that is projected onto the wire, filtered by disclosure.score_metadata, so
argument values there would be the end company's data one declaration away from
leaving — and the declaration would look, to whoever wrote it, exactly like
disclosing a model name. §5 keeps that bag numeric.
The recording stays gated behind Suite.record_responses, unchanged and still
off by default. A suite that does not opt in stores nothing, and its argument
assertions keep working, because they never read the document.
3. The passenger rule (ADR 0014 §1)¶
Two passengers, and they pass for different reasons, which is the point of filling the table in rather than asserting the result:
| passenger | 1 — the hash | 2 — the migration | 3 — the boundary |
|---|---|---|---|
tool_calls (§1) |
recording changes no score, pairs no verdict differently and moves no bar; record_responses and disclosure have never been in the fingerprint |
() — a run that recorded no trajectory had none, and none is recoverable |
payload, and no Disclosure releases it: it rides the structure redact() already drops whole |
resumed_at (ADR 0017 §11) |
a fact about the process, not about the suite | absent, which is not recorded and is never an invented time | a fact about the software house's own instrument; travels in clear, like digline_version and promoted_at |
resumed_at boards here because ADR 0017 §11 pre-argued it against these three
conditions and then declined to move the schema for it alone — "it does not ride
today because nothing forces the schema to move today". Something does now, and
ADR 0014's economics are explicit that a bump should carry as much as it
honestly can.
_STEPS gains 10: _add_schema_eleven, and it is the second step that writes
nothing: tool_calls absent already reads as the empty tuple, and resumed_at
absent already reads as not recorded. Every key it could add would mean what
its absence means, and adding them would churn every committed baseline in the
world to say nothing. _NON_ADDITIVE gains no row and no document is refused.
4. The replay hands back what it recorded, and refuses when it cannot¶
Replay.__call__ reconstructs the metadata a trajectory assertion reads:
metadata={"tools": [...], "tool_calls": [...]}
tools is rebuilt from the recorded names so that ToolsCalled re-judges
identically, and tool_calls carries the arguments for §5. The key that is
not written is the one that would lie: a source run that recorded no
trajectory writes neither key, so the assertion takes its nobody reported
branch and errors — which is the honest outcome and is why §6's refusal exists
to catch it first.
A fifth refusal joins ADR 0015 §6's four, and it is checked in _check before
the driver starts and before a judge is paid:
the suite holds a trajectory assertion and the stored run recorded no trajectory — refused by name, naming the assertion and the flag.
Refused rather than errored, because an errored check is the defect this ADR exists to close. A replay that quietly produced an unjudged row would be the gate-that-is-not-a-gate wearing a different hat.
5. The assertion: ToolsCalled untouched, ToolCalledWith added¶
ToolCalledWith(tool="lookup", arguments={"id": "…"}, match="exact" | "subset")
ToolsCalled does not change. It owns the order and the names, and its
docstring already refuses "a second semantics" — re-litigating order inside a
second class is precisely that. The two read the same trajectory and answer
different questions, which is the only arrangement in which a reader can read
either verdict without asking which one is in force.
One class with a match parameter, not ToolCalledWithExactly and
ToolCalledWithSubset. They differ by one comparison and every rule around them
— the empty mapping, the missing tool, what the reason says — would otherwise
be written and maintained twice. That is Affix's precedent and its argument.
The answers ADR 0004 asked this assertion to bring:
argumentsis mandatory and has no default. A matcher with nothing to match passes on every trajectory, which is fixed decision 3's vacuously green assertion.match="exact"is mapping equality after canonicalisation.match="subset"requires every declared key to be present and equal, and ignores the rest — which is what a suite wants when a model adds arequest_idnobody declared.- A predicate is not in this record. §"Not decided here".
- The tool is named, and a trajectory with no call to it is a
fail, not an error: the model not calling what it was told to call is the finding, not a missing measurement. - Absent trajectory is
error, onToolsCalled's rule and its wording. Unparseable arguments areerror, notfail, onJsonSchema's rule: an undecodable value is a different problem from a mismatched one, and conflating them makes the diff unreadable.
What reaches Score.metadata is a measurement and never a value:
{"arguments_matched": int, "arguments_expected": int}
Both cross a boundary on their own merit by travels(). No argument text enters
the bag, so no suite has to declare anything and no reviewer has to notice that
they should have. A reader without any disclosure still learns that two of three
declared arguments matched.
6. tools stays names, and the reason is a silent corruption¶
The trajectory arrives under its own key, beside tools and never inside it.
ToolsCalled reads its input as tuple(str(name) for name in found). Had
tools widened from names to records, str({...}) would stringify a mapping
and the assertion would compare rendered dictionaries against expected names —
failing, not erroring, on every case, with a reason that reads plausibly.
A widening that turns a passing gate into a silently failing one is not a
widening; it is the kind of change that is found a release later by somebody
re-reading a baseline.
So: Completion.tools keeps its type and its meaning, and the live trajectory
reaches an assertion as its own key in Response.metadata, beside tools
rather than inside it. Every existing reader stays correct by construction.
Completion itself gains nothing in this release, and that is ADR 0004's own
rule applied to this record rather than an omission: no plugin fills such a
field in this scope, and a field no assertion reads is a dead field. The
example's target fills Response.metadata directly, which is what makes it
independent of the plugins. Completion widens when the plugin track ships and
something fills it — §"Not decided here".
7. The example: an agent under the gate¶
examples/langgraph/ is the tenth example and the real consumer this field
needed. A LangGraph agent with two tools — a lookup and an action — where which
tool, with what arguments, and in what order carry the behaviour.
- The target is a plain function, in process, as
examples/langchain's is. No server and no port. It reports its trajectory by fillingResponse.metadataitself, which is what makes this example independent of any provider plugin: the plugins' own widening is a separate track with its own release (§"Not decided here"). - The output is the projection, not the message list. The target returns the
final text as
outputand the ordered(tool, arguments, result, status)beside it. This is not a convenience: LangGraph mintsToolMessage.idas a uuid4 with no public hook to pin it, so the raw message list is not stable across processes and a committed baseline built on it would churn on every run. The projection was measured byte-identical across separate processes. Dropping the id is decision 9 behaving correctly — it is payload-side and carries no evaluative meaning. - The model is faked and the tools are real. No stand-in in
langchain-coresupports tool calling — all five raiseNotImplementedErrorfrombind_tools— so the example ships a three-line subclass that accepts the binding and replays scripted messages. The tool bodies execute genuinely, soresultis computed andstatusis earned rather than scripted. - Zero telemetry, stated rather than assumed (fixed decision 5).
langsmithis a hard, non-optional dependency oflangchain-core. It was observed to open no socket with a clean environment, and the example pins it shut anyway:LANGSMITH_TRACING=falseandLANGCHAIN_TRACING_V2=false, set in the workflow environment and not from inside Python, because the lookup is cached on first read and a late assignment is ignored. Four names are live across two namespaces; two are pinned and the reason is written down here so the next reader does not have to rediscover the cache.
8. Five lines the report has been missing¶
Decisions of record from the triage of 2026-09-11. They ride this release
because it is the first one to touch report/ and explain/, and because
§"Consequences" of ADR 0014 is right that the next bump should be expensive.
Adding to explain's fact list amends ADR 0012 §3, which closes it. The
amendment is declared in this record's header and each line below earns its
place by that section's own test: it says something the report says, and a
reading that omitted it would describe a measurement that was not made.
- On the line. A check whose measured interval contains its threshold is
named, in a section of its own, distinct from passing and failing. Its
pass or fail is an accident of which samples were drawn, and a reader who is
shown it as a clean pass has been told something the measurement does not
support. The reading of "the measured band" is the one this codebase already
has:
sample_min <= threshold <= sample_max, at storage precision and inclusive, by ADR 0009 §1. An unsampled check has no interval and is therefore never on the line — an honest absence rather than a computed one. The exit code does not move. This names a fact; it does not gate. - The judge-changed lead. Where
judge_configdiffers from the reference,explainopens with it, before any number: the judge changed: scores are not comparable to the reference. The fact has existed since ADR 0005 and is reported today in last position, after the counts it qualifies. The promotion to the lead is the change, andrejudgedis the precedent — it is first "because it qualifies every count under it", and so does this. - Overlap, therefore no claim. Where the two intervals overlap, the sentence goes at the head of the diff's report rather than only on the check. Two systems that are not distinguishable by a check is a conclusion about the comparison, and a conclusion that appears only in a per-row detail is one a reader assembles for themselves or not at all. Silent at zero, on the precedent the diff head already sets for its other clauses.
- Per-call exceedances. N of M judgments exceeded the per-call figure
(max $X) — where a
cost_budgetpasses on the fold and individual samples did not. A ceiling declared per call and checked only on the mean is a ceiling that is not the one the suite declared, and the run that surfaced this had 9 of 720 over it.
Derived, not newly measured, which is what keeps it out of §3's table.
budget_score_at_precision forces a sample's stored score onto the side of
the threshold that within(measured, cap) puts it on, so
meets(sample, threshold) is the within-cap test: N is an exact count
over Score.samples, which the document already carries. The figure itself
inverts the score — cap * (1 - s) / s, with the cap read from max_usd,
which survives the fold unchanged because a constant averaged with itself is
itself — and the worst call is sample_min.
One honest limit, stated rather than discovered later: a sample within about
a millionth of the cap is pinned to the threshold by that same clamp, so an
inverted cost would read as exactly at the cap. The line reports such a
sample as at the cap rather than printing a figure the clamp chose.
Silent where nothing was sampled, on the diff.exceeds precedent — 0 of 0
reports an absent measurement as a null result.
5. The three-way spread. Where a judge's samples fall in more than two
directions, the spread is reported per category rather than as a single
number. Two directions is disagreement; three is a judge that is not
measuring one thing, and a spread of max-minus-min reports both as the same
figure.
Derived for a check that scored, and one thing had to change for the check
that did not. The raw vector is on Score.samples, is serialized and
survives redaction, so the three counts are available from any stored run
that produced a score. But the verdict this line exists to explain — the one
that errors under the agreement floor — is built by a branch that
carried no samples and no metadata at all, so the single check a reader most
needs explained was the only one holding nothing to explain it with.
So the refusing verdict now carries the counts the fold has just computed:
samples, agreement, errored_samples and scores. No schema field —
Score.metadata is a free-form bag and this adds no key to the document's
shape — and all four are numbers, so all four cross a boundary on their own
merit by travels(). A suite that never trips the floor produces the bytes
it produced before.
The category is not decided here. A group never reaches a run file —
ADR 0010 §1 keeps it inside the aggregate's name and nowhere else, and
CaseResult, CaseOutcome and Verdict all carry none — so a per-group
figure is computable where the Suite is in hand and not from the document
alone. Until that is ruled, the line ships per check, which is computable
from the document and says the same thing about the judge.
Every string is added in both locales, and the no-advice gate of ADR 0012 §5 runs over them like every other. ISO dates and the decimal point stay unlocalised, so two readings of one run remain comparable line by line.
9. What the bump costs, in order¶
ADR 0014 §6, unchanged and not negotiable, because the tree is red until it is finished:
pyproject.tomlto the version carrying schema 11.- A row in
RELEASEDintests/test_example_caps.py. digline migrateover every document underexamples/*/.digline/.- Every example cap raised — all nine sit at
<0.12today and admit no release that can read a schema 11 document. - The rendered report pages regenerated, in the established order: commit the example, render, commit the report on top.
- The window, stated rather than hidden: between the bump and the release reaching the index, a reader cannot resolve the examples at all.
The plugin floors gate is name-based, so the bump forces no plugin release.
A plugin that later widens a field rather than importing a new name is
invisible to that gate and must raise its floor by hand, with a comment saying
why — the precedent is pytest-digline's, written for the same hazard.
Consequences¶
A gate that silently was not one becomes one. Every suite holding
ToolsCalled has been unre-judgeable since rejudge shipped, and the failure
mode was an errored row rather than a refusal. It is now one or the other, by
name.
The arguments arrive where ADR 0004 said they would, at the cost it predicted plus one it did not. The record gains them without widening what travels; what it did not foresee is that the re-judge would have to be taught to hand them back, and that this is what makes the field a document field at all.
Two passengers make this bump cheap and the next one expensive. Nothing here
reserves a field for later use; resumed_at boards because it was vetted in
advance and something finally forced the move.
A check that is on the line will be read as a new kind of failure by somebody. It is not one, and the exit code says so. What it is is the instrument admitting that a verdict rests on which samples were drawn.
Alternatives considered¶
Arguments in Score.metadata. Rejected in §2. It is the one persisted bag
that is also projected onto the wire, so this would put customer data one suite
declaration away from leaving, behind a declaration indistinguishable from
disclosing a model name.
A free-standing tool_calls on CaseResult. Rejected: it would need
redact(), wire/, and promote_baseline each taught about it separately,
which is three chances to get a boundary wrong in exchange for nothing that
RecordedResponse does not already provide.
Widening Completion.tools to records. Rejected in §6: str() over a
mapping turns a passing gate into a silently failing one.
Shipping the assertion with no schema move. Genuinely available, and rejected on the strength of the defect rather than on the feature: the assertion works without the document, but the re-judge does not work without the record, and leaving that open would ship an assertion whose replay silently errors.
Recording the raw message list in the example. Rejected in §7: it is not byte-stable across processes, and a committed baseline that churns is a baseline nobody reads.
Making on the line a status or an exit code. Rejected. The exit codes are
the contract (AGENTS.md §6), and a third outcome would break every gate in
every user's CI to report something that is not a regression.
Test plan¶
Beyond a failing case for every new rule, which the conventions already require:
The boundary, planted. tests/test_wire_boundary.py's marker suite gains a
tool-argument marker: a recorded argument whose value is a unique token. The
assertion is that the token appears in the run file written inside the perimeter
and in nothing produced by redact(), run_to_json(..., redacted=True),
run_document, compare_json, delta_json, explain_json, or the rendered
report of a redacted run.
The fifth refusal, on its sentence. A suite holding a trajectory assertion, replayed over a run that recorded no trajectory, is refused by name — asserted on the line a user acts on, not on the exception type.
The replay is the same measurement. A suite with ToolsCalled and
ToolCalledWith, run against a stubbed target with record_responses=True and
then re-judged, produces the same scores case for case. Then the same replay
with a changed arguments produces the expected flip, which is the feature.
Promotion strips, and the count survives redaction. A promoted baseline
carries no trajectory; a redacted document carries withheld=True and the
count, and neither carries an argument.
The default is byte-identical. A suite without record_responses produces a
run file identical to the one the previous release produced, modulo
schema_version — asserted on the bytes, as ADR 0006 §11's test does.
Both locales, and the gate. Every string added by §8 exists in en and
it, the key sets stay identical, and the no-advice gate runs over the new
strings.
On the line is measured, not asserted. A check whose interval straddles its threshold is named; one whose interval clears it is not; an unsampled check never is. The exit code is asserted unchanged across all three.
Not decided here¶
A predicate matcher for arguments. A callable fingerprints as its type name
in AssertionBase.identity, so two different predicates would be
indistinguishable to compare() — which is a real limit and needs its own
answer about how a suite declares what it is checking. exact and subset
cover the cases in front of us.
The provider plugins' own widening. All three already hold the arguments and drop them one expression later, and each would need its floor raised by hand (§9). It is a separate track with its own release, and the example deliberately does not depend on it.
Arguments crossing a boundary. The answer is no, by §2, and it will be asked — an end company asking for its own data back is entitled to it. The route is the perimeter they already own, not a flag that makes every other suite's arguments one line from travelling.
Capture — turning an application's own traffic into cases. Adjacent and still unbuilt; it consumes an application's log, not a stored trajectory. It keeps its own future number rather than reserving one here.