- Status: accepted
- Shipped: 0.1.3
- Date: 2026-08-28
- Amended: 2026-09-10 — §6, the completion record. Added rather than a new ADR: it widens the one method §2 gives a plugin and overturns nothing above it, which is the test ADR 0005 §8 was added under
- Amended: 2026-09-19 — §7, the judge's abstention. It changes the judge
contract this record owns —
SCORE_SYSTEM,CLAIM_SYSTEMand what a reply may say — and overturns nothing above it, so it is an amendment by the same test. Schema-free: no field reaches the run document, no version moves, no baseline is re-promoted - Refines: fixed decision 6 (
CLAUDE.md), "providers as plugins" - Assumes: ADR 0001 (the judge returns a reply, the core decides the verdict), ADR 0002 §2 (the payload stays where it is born)
Context¶
digline.core declares Judge and ClaimJudge and implements neither: the
judge is the one route an assertion has to the outside world, and it is
injected, which is what keeps an assertion a pure function. That is right and
nothing here changes it.
What is missing is the other side. No package ships a judge at all. Every
judge that has ever run against a real model in this repository was written by
hand in a suite — examples/prompt-first/suite.py::_live_judge is twenty lines
of SDK call, prefill, JSON decoding and float(data["score"]), and it is
copied, not imported. digline-anthropic ships AnthropicTarget and stops
there. So the plugin boundary, as built, says "a provider is somewhere you send
a prompt to get an answer" and forgets that judging is sending a prompt to get
an answer.
Three things follow, and only the third is the reason this is an ADR.
The parsing is duplicated. A model asked for JSON returns JSON in a fence, JSON with a sentence in front of it, or JSON with a trailing comma. Every suite that judges rediscovers this, and rediscovers it in the place where getting it wrong turns into a score.
The judge's cost is invisible. Response.cost_usd is the target's call.
A suite with samples=5 and Repeated(n=3) makes fifteen judging calls per
case and reports the cost of five, so a run's stated cost is not what the run
cost. Fixed decision 4 makes cost a budget; a budget over a number that omits
half the spending is a budget over the wrong number.
The judge sees the payload. This is the one that decides the shape. What a
judge is sent is the model's output — the thing ADR 0002 keeps on the near
side of the perimeter — and today choosing where to judge is a separate,
unhelped decision from choosing where to generate. A software house running a
customer's model on that customer's own OpenAI-compatible endpoint (Azure in
their tenant, a vLLM in their VPC) has no judge to reach for except somebody
else's API, which sends the output out of the perimeter it was generated in.
Every assertion in the suite is then evaluated on data that has left, and no
Disclosure in the suite says so, because disclosure governs what leaves the
run document, not what an assertion did while producing it.
A judge that cannot live where the output lives is a hole in decision 9 that no amount of care in the report can close.
Decision¶
1. A plugin is a target and a judge, both¶
Every provider package under packages/ exports, at minimum:
<Provider>Target |
(Case) -> Response, as today |
<Provider>Judge |
(prompt: str) -> JudgeReply — satisfies Judge |
<Provider>ClaimJudge |
(prompt: str) -> ClaimReply — satisfies ClaimJudge |
Both judge protocols, not one. They answer different questions and the core
keeps them apart on purpose (Judge decides a score, ClaimJudge reports two
counts and the core divides); a plugin that shipped only the first would leave
Faithfulness — the assertion with the strongest reason to run inside the
perimeter, since it is handed the retrieved context too — with nothing to run
on.
Choosing a provider is then one decision. Whoever can generate can judge, in the same perimeter, with the same key, against the same price list.
digline-anthropic gains the two judges in the same change that introduces
digline-openai. A symmetry that holds for one plugin out of two is not a
symmetry, it is a coincidence waiting to be codified wrongly.
2. The half that is not the provider lives in digline.targets¶
ProviderTarget already carries everything about a provider call that is not
the provider. Judging gets the same treatment: digline.targets.JudgeBase and
its two subclasses ScoreJudge and ClaimCountJudge compose the system prompt,
time the call, price the tokens, parse the reply and build the JudgeReply or
ClaimReply. A plugin writes _complete, which is the same one method a target
writes.
This is digline.targets and not digline.core: the core stays pure, imports
no SDK, reads no clock and keeps receiving its judge injected. JudgeBase sits
where ProviderTarget sits — above digline.run, below the CLI, with the
layering gate forbidding an SDK import in both.
The system prompt is written once, here, and it is written against the shape
judge_prompt() produces — instruction, labelled sections, output last behind
Output to judge:. That shape is documented in docs/api.md because anyone
writing a judge has to parse it; with the judge in the box, the two halves of
the contract are finally in one repository and one test can hold them together.
3. What judging cost is counted on the judge, and stays in the process¶
JudgeReply does not grow a cost field. It is a value that crosses into the
core and gets validated there; a cost is not part of a judgement, and putting it
there would make every fake judge in every test carry a number it has no opinion
about.
Instead the judge object accumulates:
calls |
judging calls that returned |
spent_usd |
their cost, from the plugin's price list |
latency_ms |
their total duration |
They are never reset. Not per run, not per case, not per execute(). A
judge is constructed by the suite and lives as long as the process, so a reader
of judge.spent_usd gets "what this judge has spent since it was built", which
is a fact that needs no lifecycle to interpret. A caller wanting a per-run
figure takes the delta itself — two reads and a subtraction, which is honest
about what it is measuring. The alternative, resetting on some boundary the
judge cannot see, would make the number depend on who called the reset and would
be wrong exactly when a suite is run twice in one process.
A call that raises is not counted: its cost is unknown, and counting it at zero would be the undercount that reads as good news.
This is in-process only. It is not written to the run file, does not enter
compare() and does not appear in the report. Said plainly because it is a real
limitation and not an oversight: "what did judging cost" is a number we will
want in the report, and putting it there is a change to the run schema, to
compare() and to the document world 3 reads — three things this ADR does not
touch. Today a suite that needs the figure asserts on it or prints it. When it
moves into the run, it gets its own ADR, and the first question that ADR has to
answer is whether a judging cost is a measurement (it crosses) or a fact about
the software house's own bill (it does not).
4. The reply is parsed leniently, and response_format is an optimisation¶
base_url makes one target cover every OpenAI-compatible provider, and those
providers do not agree on structured output: the official endpoint honours
{"type": "json_object"}, some vLLM builds honour it, Ollama may reject it
outright. A judge that depended on it would work on the endpoint we tested and
fail on the one a customer runs.
So: it is sent when it might help and never required. A provider that
rejects the parameter is retried once without it, and the choice is remembered
for the life of the judge. The reply is then read by a parser that accepts a
bare object, an object inside a ```json fence, and an object with prose
around it — the three shapes a model actually returns.
Lenient about the wrapping, strict about the content: a missing score, a
score outside [0, 1], a missing reason, supported > total all raise, and
LlmRubric turns the exception into error. Neither green nor a
regression: the judge failed to answer, which is a third thing, and ADR 0001
already decided that a judgement that could not be made is not a passing one.
5. No plugin reads the environment, and a sentinel is only for a custom endpoint¶
The key resolution rule, which is what makes "the same target covers Ollama" a sentence rather than a special case:
api_keygiven → it is used;api_key=Noneand the official endpoint →Noneis passed to the SDK, which resolvesOPENAI_API_KEYitself and raises its own message if there is none. The plugin contains noos.environ, nogetenv, and the existing test indigline-anthropicthat enforces exactly that stays true of both plugins;api_key=None, a custombase_url, and the SDK found no key in the environment → the client is built with the sentinel"digline-no-key", a value that is obviously not a credential and is documented as one. A local server does not look at it; a remote one rejects it with an authentication error, which is the right error.
The order matters and is the whole trick: the sentinel is only reached after
the SDK has looked, so pointing at OpenRouter with OPENAI_API_KEY set still
authenticates. The plugin never learns whether a key exists.
The key never appears in a Response, in Score.metadata, in an artifact, in
an error message, or in the repr of a target or a judge. It is credential,
which is the one category of payload that has no Disclosure that can release
it. Tested, not asserted.
6. _complete returns a record, not a pair¶
Amendment, 2026-09-10. §1–§5 stand unchanged. This widens the one method a plugin writes, and it is the oldest contract debt in the project.
The pair was decided when the only question asked of a provider was what did
it say and what did it cost. Two needs have arrived that the pair cannot
carry, and this repository already names the first one against itself, in the
docstring of _no_text:
What is not here is the provider's own
finish_reason/stop_reason. That would say which of the two it really was rather than which it looks like, and it cannot reach this function:_completereturns(text, Usage)and nothing else, in every plugin and in the abstract method. The numbers below are what this layer holds, so they are what it may claim.
_no_text is the sentence an operator reads when a judge produced nothing. It
infers — "likely truncated", "a non-text reply or a refusal" — from a
token count against a cap, because inference is all the pair leaves it. All
three providers return the answer as a field, and have all along.
The second need is the trajectory. An agent that was supposed to call
search and answered from memory is a regression digline cannot see: the tool
call is a content block, and text_of drops it on purpose — joining it in would
put a repr into the output the assertions read. So the one fact that says
whether the agent did the work is discarded one line before the Response is
built.
So _complete returns a Completion.
| field | type | |
|---|---|---|
text |
str |
as before |
usage |
Usage |
as before |
finish |
Finish \| None |
how the turn ended, in one closed vocabulary |
finish_raw |
str \| None |
the provider's own word, verbatim and uninterpreted |
tools |
tuple[str, ...] \| None |
the tools the model called, in order — None where none were reported |
model |
str \| None |
what the provider said answered |
fingerprint |
str \| None |
the backend build, where a provider names one |
A record and not a longer tuple, and not because seven is more than two: a
tuple's meaning is positional, and every plugin author would have had to
count. Everything after usage defaults, so a provider that can say none of
it writes Completion(text, usage) and is done — which is not a hypothetical,
it is digline-bedrock on two of the five.
tools carries the absent/empty distinction on the same rule §1 of ADR 0005
reads an unset parameter by: () is the model called nothing, None is
nobody reported. They are different facts and only one of them is judgeable.
The last two fields are passengers rather than cargo: what they are for is
ADR 0005 §9, and they are
here because this is the vehicle. Widening _complete twice for two facts that
arrive in the same reply would be two plugin releases and two floor bumps for
one journey.
Third-party plugins are unaffected, by construction¶
_complete is typed Completion | tuple[str, Usage] and the base normalises
what it gets. A plugin written against this ADR as it stood returns the pair,
the pair is read as a Completion with every new field at its default, and
nothing about its behaviour moves. This is the shape ADR 0005 §6 chose and the
sentence it chose it with: the protocol did not gain a mandatory member, so
nothing anyone has written stops satisfying it.
The union is permanent, not a deprecation window. There is no release at which the pair becomes wrong: it is the honest return for a provider that has nothing to report beyond text and tokens, and such a provider exists — every OpenAI-compatible server that answers with neither a finish reason nor a model id, which is most of the small ones.
One vocabulary, and the provider's own word beside it¶
The three name the same events three ways. Measured by reading the shipped SDKs, not recalled:
finish |
Anthropic stop_reason |
OpenAI finish_reason |
Bedrock stopReason |
|---|---|---|---|
stop |
end_turn, stop_sequence |
stop |
end_turn, stop_sequence |
length |
max_tokens, model_context_window_exceeded |
length |
max_tokens, model_context_window_exceeded |
tool_use |
tool_use |
tool_calls, function_call |
tool_use |
filtered |
refusal |
(see below) | guardrail_intervened, content_filtered |
other |
pause_turn |
— | malformed_model_output, malformed_tool_use |
finish is normalised because an assertion that had to spell "stop" against
one provider and "end_turn" against another would not be one assertion, and
fixed decision 1 is that there is one assertion engine with two drivers.
finish_raw is kept beside it because the normalisation is lossy exactly
where a regulated reader cares. refusal, content_filtered and
guardrail_intervened all become filtered, and which of the three fired is a
fact somebody will be asked for; max_tokens and model_context_window_exceeded
both become length, and your cap and the model's window are two different
things to go and fix. Recorded, never interpreted, never the subject of an
assertion — an assertion on it would be unportable by construction, which is
the argument for the normalised field one row up.
A word the table does not know maps to other, and keeps the word. It does
not map to stop: calling an unrecognised ending "it finished normally" is
fixed decision 3's vacuously green assertion arriving through a different door,
and a provider that adds a stop reason next quarter must not turn a truncated
run green on upgrade.
OpenAI has no refusal value in finish_reason. The refusal arrives one
level down, as message.refusal, while finish_reason stays stop. So the
OpenAI plugin is the one place a plugin composes an answer rather than
translating one: a present message.refusal is reported as
finish="filtered", finish_raw="refusal". Declared here rather than
discovered in the code, because it is the single case where the record says
something the provider did not say in one field.
The names, not the arguments¶
tools holds names. The three disagree about what an argument even is —
OpenAI returns a JSON string whose own SDK docstring warns that the model
"does not always generate valid JSON", while Anthropic and Converse return a
parsed object — so normalising means either parsing what may not parse, or
re-serialising what was already structured and letting two identical calls
differ by whitespace.
They are left out for the harder reason too: nothing would read them. The assertion below checks names. A field no assertion reads is a dead field, and an argument that some later suite might want it is the argument that fills a contract with dead weight. When an assertion needs the arguments it brings their shape with it, and adding the field then costs nothing at all — see "where the record lands" below.
_no_text stops guessing¶
The judge's sentence becomes a read cause instead of an inferred one, in the one case where the two disagree most: a model that answered with a tool call and a model that was cut off at the cap look identical to a token count when the cap was also reached, and they need opposite fixes.
With finish in hand the sentence names what happened — "the judge returned
no text: the provider reported length (max_tokens), so it was truncated
before the first character" — and falls back to today's inference, word for
word, when finish is None. The fallback is not a courtesy: a judge on a
compatible endpoint that reports no finish reason is the ordinary case, and the
sentence that serves it is the one that is there now.
The status does not move. A judge that returned no text did not judge, and that
is error before and after — ADR 0001's third outcome, unchanged.
The consumer: ToolsCalled¶
A record whose fields nothing reads is a record nobody maintains, so the trajectory gets its assertion in the same change:
Pure, in digline.core, reading inputs.metadata["response"] — which is what
the mapper carries in from the driver, and the one route an assertion has to a
fact about the response rather than the output.
expectedis mandatory and has no default. A trajectory assertion with nothing to check is fixed decision 3's vacuously green assertion, and a default of()that passes on every response is the shape it would take.- The check is the sequence of names, in order, binary: 1.0 or 0.0 against a threshold of 1.0. Not a set, not a ratio, not an "at least these" — every one of those is a second semantics, and a reader who has to look up which one is in force cannot read the verdict.
- A response that carries no trajectory errors. A plain-function target and
a provider that reports no tool calls both give
tools=(), and()is indistinguishable from the model called nothing. Reportingfailthere would be a finding nobody established; reportingpasswould be the same thing in the direction of good news. The record therefore distinguishes no trajectory reported from an empty trajectory, and only the second is judged. This is the same third outcome the judge gets when it cannot answer. finishgets its second consumer here, and it is not decoration: a provider that reportsfinish="tool_use"alongside an emptytoolshas contradicted itself — a compatible endpoint that emits the call as text is the common way — and the assertion errors with that sentence rather than reporting that the model called nothing. The pair of fields catches what neither catches alone.
Score.metadata carries what the assertion measured, which is the only
metadata that crosses a boundary (fixed decision 9): the count as a number and
the names as strings. Under travels() the count crosses and the names do not
— a tool name is a string, and no string crosses Score.metadata on its own
merit. That is the rule working rather than a gap in it: a suite that wants the
names in a redacted document declares them, exactly as it would a model name,
and a reader who gets no disclosure still learns that three tools were called
where two were expected.
Where the record lands, and what it costs¶
Almost nothing, and the reason is worth stating because it is what makes this release small:
Response.metadatagains the fields — andResponseis not persisted.CaseResultkeepscase_id,verdictsandsuspended, so nothing here reaches the run document,compare(), the report orSCHEMA_VERSION. What reaches the document is what an assertion measured, throughScore.metadata, under the rule that already governs it.modelandfingerprintdo reach the document, and they reach it throughtarget_config— the record, not the hash. That is ADR 0005 §9, and the reason it is over there is that what a plugin returns and what a run records are two contracts with two readerships.
Usage is untouched, Pricing is untouched, and no cost anywhere changes
value.
Consequences¶
- A suite on any OpenAI-compatible endpoint can generate and judge inside one perimeter, with one key and one price list. That is the hole in §Context closed, and it is closed by construction rather than by a warning in a document.
- Twenty lines of SDK-and-JSON leave every suite that judges.
_live_judgeinexamples/prompt-firstcan become an import — once 0.1.3 is on the index, since the examples install digline from PyPI rather than from the workspace. - A plugin is a bigger object than it was: two more classes and a system prompt per provider. The cost is real and it is paid once per provider, in the package whose whole job is knowing that provider.
- The judging spend is now knowable in-process and still absent from the run document. Anyone reading a run file for a total cost is reading the target's cost, as they were before this ADR — no number changed meaning.
-
digline.targetsgrows a second base class, and the layering gate grows the obligation to keep an SDK out of it. Same rule, one more file. -
_completecan answer the question it is asked. The judge's sentence about a silent reply stops being an inference and becomes a reading, in the one case where inference and reading disagree. - An agent's trajectory is assertable for the first time. Before this, a suite could tell that the answer was wrong and never that the model skipped the tool that would have made it right.
- Every first-party plugin ships a release and the floor moves with them; a third-party plugin ships nothing and behaves identically, because the pair it returns is still a return this contract accepts and always will be.
- The core gains one assertion and the six test doubles across this repository gain nothing — a fake that returns a pair keeps working, which is the same guarantee the third-party plugins get, tested by the fakes not changing.
Response.metadatais a wider bag than it was, and it is still a bag nothing persists. Anyone reading a run file for a finish reason will not find one: the trajectory travels as a verdict, through an assertion that measured it.
7. A judge may say it cannot answer¶
Amendment, 2026-09-19. §1–§6 stand unchanged. It widens what a reply may say, and nothing that reads a reply today reads it differently.
It ships with its twin. 0.16.0 already carries the fix that stopped
LlmRubric and Faithfulness blaming the judge for a prompt it was never
shown. That one stops the instrument being accused when it is innocent; this
one gives the instrument a way to say it cannot answer. Both are about the same
sentence — the one somebody reads to decide whether to re-run or to
investigate — and neither needs a schema.
7.1 What a judge can say today, and what it cannot¶
A judge has exactly two ways to end: return a JudgeReply it validated, or
raise. So "I cannot score this" has to arrive as an exception, and the
assertion reports it as the judge raised … — the same sentence as a
timed-out HTTP client, a decoding failure, or a bug in the plugin. The judge's
only declared answer is a number, and a model that is genuinely unable to score
an output — a refusal, an empty answer, text in a language the rubric does not
cover — must either invent a number or crash.
Inventing is the worse of the two and it is the one models do. A score of 0
for "I could not read this" is a fail, and a fail is a statement about the
system under test. That is the measured thing being marked down for the
instrument's inability, which is the failure mode this whole record exists to
keep apart from a real one.
7.2 Not a fourth status¶
Verdict has three states and ADR 0001 fixed them. An abstention does not
add a fourth, and it does not need one: error is already "a judgement that
could not be given", which is precisely what an abstention is.
What changes is the reason. Today an unjudged check carries our sentence about the judge; after this it can carry the judge's own sentence about the output — "the answer is a refusal to help, so there is nothing to score against the rubric". That is more information than today at the same status, which is the cheapest kind of improvement this project can buy.
An abstention is also a paid call. JudgeBase._ask counts calls,
spent_usd and tokens before it parses anything, so an abstaining judge is
billed exactly like a scoring one — correctly, because the model was asked and
answered. Only a call that raises goes uncounted, and that rule is unchanged
(ADR 0025 §4).
7.3 The declared form: unreachable by accident¶
The reply shape gains one key, and the rule about it is the whole of this section:
{"abstain": true, "reason": "<why this output cannot be scored>"}
Never {"score": null}, and never a missing score. Those are what a
confused model produces, and they must keep meaning this reply is broken. An
abstention that could be reached by omitting a key would be reached by
omission — and then every malformed reply in the world becomes a judge
declining, which is the one reading that cannot be told from the truth.
Three rules make it declarative rather than accidental:
abstainmust be present and true. Absent is not an abstention. Afalseis not an abstention. A string, a number ornullunder that key is a malformed reply, not an abstention — the same refusal_numberalready gives ascorethat is not a number, and for the same reason: a key that accepts anything is a key that means nothing.reasonis mandatory, and it is refused when empty — by_reason, which already exists and already says the sentence a debugger needs. An abstention with no reason is refused as a broken reply, because an abstention whose whole value is the judge's sentence is worth nothing without one. This is the one place where abstaining is stricter than scoring: a score can be checked by arithmetic, a declining can only be read.- It is checked before the score. The parser looks for a declared
abstention first; only if there is none does it require a
score. A reply carrying both is an abstention — a judge that declined and then supplied a number has not scored, it has decorated, and reading the number would be reading a value nobody stood behind.
7.4 How it reaches the core: an exception, not a wider reply¶
The mechanism is a dedicated exception, JudgeAbstained, declared in
digline.core beside the reply types, raised by the judge and caught by the
assertion before the generic except Exception that reports a raise.
try:
reply = self.judge(prompt)
except JudgeAbstained as declined: # the judge answered: it cannot score
return self._error(f"the judge declined to score: {declined}")
except Exception as exc: # the judge failed
return self._error(f"the judge raised {type(exc).__name__}: {exc}")
It lives in core because the assertion catches it and core may not import
targets; plugins and hand-written judges import it from there, which is one
import and no signature change.
Two alternatives were weighed and refused.
JudgeReply.score: float | None. Rejected. It makes the score optional for
every consumer of every reply in order to express a state that occurs rarely,
and the first consumer that forgets the None reads an abstention as a zero —
the exact confusion §7.1 is about, reintroduced one layer down. It would also
weaken the type that exists to be the strictest boundary in the system.
A union return type, JudgeReply | Abstention. Rejected on compatibility: it
changes the Judge protocol's return type, so every hand-written judge in
every suite — and the six test doubles in this repository — stops satisfying it
until its annotation is edited. An exception is invisible to a judge that never
raises it, which is what §7.5 needs to be true.
Why control flow is honest here. An abstention ends where an exception ends — in an errored verdict — and it is the unusual path by construction. The objection to exceptions is that they hide an ordinary outcome; this outcome is not ordinary, and the handler that catches it is three lines from the handler that catches a raise, so a reader meets both at once.
7.5 Backward compatibility, in one line¶
A judge that never abstains behaves exactly as today — because the parser
goes on refusing a missing or null score as a broken reply, and nothing reads
the new key unless the judge wrote it deliberately.
That line is true only under §7.3's first rule. If absence ever came to mean
abstention, every existing judge would begin abstaining the first time a model
dropped a key, and this section would be false. It is therefore tested in that
direction: a reply with no score is still an error and is not an
abstention.
7.6 CLAIM_SYSTEM, and the zero that means two things¶
Faithfulness has the one unattributable row in the product that this
amendment can reach. Today, total == 0 errors with "the judge found no
claims in the output", and that sentence is true of two different worlds:
- the output genuinely asserts nothing — a refusal, a clarifying question, an empty answer. The target produced something with no claims in it;
- the judge could not decompose it — the text is in a language it handles badly, or the context is unreadable to it. The instrument failed.
Nothing in the data tells them apart, which is why the instrument-versus-target reconnaissance listed this site as unattributable in advance.
What a claim judge abstains on. Not on finding zero claims — that is a
count, and a count is an answer. It abstains on being unable to do the
decomposition at all: it cannot determine what the output asserts, so it has
no counts to report, supported or total. CLAIM_SYSTEM gains the instruction in
those words, so the distinction the model is asked to make is the distinction
the core reads.
What Faithfulness reports then. Two errored verdicts where today there is
one, still error, still no side field:
| what happened | what the verdict says |
|---|---|
| the judge declined | the judge's own sentence, under the judge declined to count the claims |
total == 0 with no abstention |
the judge counted the claims in this output and found none — one world, not two |
The second sentence is rewritten by this amendment, and the rewrite is the point: it may only be said once the first is available. Until a judge can decline, "found no claims" cannot honestly claim the judge counted.
This is the strongest argument for the feature, and it should be stated as what it is: the abstention does not relabel rows we already understand — it turns a row nobody could attribute into one that can be. It does not record the attribution (§7.8); it makes it exist.
7.7 What the reader sees, confirmed from the code¶
Nothing here needs a new surface, and one surface will not show it. Both were read rather than assumed:
- The report shows it.
render.pyrendersverdict.reasonin the Why column of the unjudged block, gated onreasons_available, which isnot (run.redacted or baseline.redacted). So the judge's sentence reaches an ordinary reader unchanged, and a redacted document showsreason.unavailableinstead — correct and unchanged: the reason is payload (ADR 0002 decision 9), and a judge quoting an output is quoting the output. - The terminal shows it.
summary_linesprints one line per unjudged check through the samecheck_line()the report column uses, so the two cannot describe one verdict in two ways. pytest-diglineshows it. The ERROR row'slongreprcarries the reason verbatim.- A
Repeatedfold keeps it._all_erroredgroups the samples' own sentences by cause with counts, so "2 of 3:; 1 of 3: the judge raised …" is what a folded abstention reads as. A judge that declines twice and fails once is visible as both. explainwill not show it, and that is deliberate.CheckFactcarries noreasonby ADR 0012 §4 — "absent rather than emptied, because a field that exists is a field a later edit fills". So the one surface built for a machine to read will say a check could not be judged and not why. This amendment does not change that: the rule is about a boundary, not about this feature, and a field added here for one sentence is the edit that rule predicts. A reader who needs the sentence reads the report or the run document.
Nothing truncates it: MAX_FAILURE_CHARS clips reasons the driver builds
from exceptions, and an assertion's _error is not clipped.
7.8 What this does not do¶
Said plainly, because the feature is easy to oversell:
- It attributes no side. No field on the verdict, nothing in the document,
nothing in
compare(). The instrument-versus-target distinction stays a sentence a human reads, and the field that would make it machine-readable waits for the friction that names it. - It moves no exit code. An abstention is
error, and an errored check already exits 2. A suite whose judge declines everywhere exits exactly as a suite whose judge raised everywhere. - It changes no threshold, tolerance or denominator. An abstaining check is
excluded from an aggregate under
errored_excluded, which is where an errored check has always gone. - It leaves the other unattributable rows unattributable. The aggregate's
empty denominator and both
combine_samplesexits are not reached by this and are not helped by it.total == 0is the only one it closes.
7.9 The prompt moves, and nothing records that it did¶
SCORE_SYSTEM and CLAIM_SYSTEM gain the instruction that tells a model it may
decline. Two questions follow, and the answers are not symmetrical.
Does it change artifacts_changed for anyone? No. Run.artifacts holds the
files the suite declares — the prompt under test, keyed by the path the suite
gave (ADR 0003). These constants are digline's own source. No suite declares
them, so no artifact digest moves and no comparison reports a changed file.
Is a stored baseline affected? No. The judge's system prompt is in neither
config_hash — which covers assertion identities, thresholds, tolerances,
samples, min_agreement, the aggregates and a declared price — nor
judge_config, which records provider, model, max_tokens, temperature and the
observed identity. AssertionBase.identity contributes a judge's type name
and never its value. So nothing is unpromoted, nothing needs re-promoting, and
judge_config_changed stays false.
And that last answer is the finding, not the reassurance. The instrument's
instruction changed and no document records it. Scores may move across this
upgrade — a model told it may decline will sometimes decline where it used to
guess — and a comparison across it will report that movement with
judge_config_changed false, which reads as the instrument is the same. The
only trace in the document is digline_version (ADR 0014 §3), which says which
digline wrote it and not what that digline asked.
ADR 0005 §4 exists to catch exactly this class — the instrument moved, so the scores are less comparable than their difference suggests — and it cannot catch this instance, because it watches the fields a plugin reports and not the words we send. This amendment does not close that; it is the first change to make it concrete rather than theoretical, and it is recorded here so the next person meets it as a known gap; it belongs with the record that gives the two sides names, on the day that friction names itself, and not with this one.
The release note carries what a user can act on, which is more than the honest half: if judged scores move on this upgrade, it is neither your system nor your model — it is our instruction to the judge. Read the movement, and re-promote if it is acceptable. Honesty alone would leave a reader with a changed number and no next step, which is the shape of report this project refuses everywhere else.
7.10 Measured, 2026-09-23: a judge declines less when given more context¶
The first measurement of abstention against a variable that has nothing to do with whether the output is true. It is recorded here rather than left in the suite that found it, because it is a property of the instrument and it will outlive that suite.
A Faithfulness check over one-sentence Italian descriptions of RSS items. Six
recorded descriptions, each judged twice, the same text both times and only
the context changed — twelve calls, about a penny and a quarter:
| context given to the judge | declined |
|---|---|
| the item alone — everything the model that wrote the description was given | 4 of 6 |
| the item plus an unrelated system prompt describing a reader's taste | 1 of 6 |
The second context cannot make a description truer. It is a paragraph about what somebody likes to read; the descriptions are about what an article is. Every claim supported under one is supported under the other. What the extra text changes is the judge's willingness to answer at all — four times over.
The next paragraph is the one untested part of this section. The effect is measured, the mechanism is a guess, and nothing in the section rests on it.
The mechanism this section's own design invites: §7.3 makes declining a deliberate act with a stated reason, and the reasons these judges gave were of the form "it is impossible to verify which claims are supported versus inferred from external knowledge." More context means more surface to relate a claim to, so the judge finds a foothold rather than a reason to abstain. Sparse context reads to it as insufficient grounds.
What follows for a suite author, and it is uncomfortable: the honest context — what the system under test was actually given, which §7 and ADR 0024 both assume — is the one that draws the most abstentions. Padding it makes the instrument more compliant without making it more correct, and a suite that quietly does so is buying answers rather than measuring. There is no rule here that would stop it, and this paragraph is the only place that says it happens.
Not a decision, and nothing changes. Six pairs is enough to see a four-fold difference and not enough to size it, the descriptions came from one prompt and one model, and no threshold, refusal or default should be built on it. It is written because the next person to see a suite abstain will reach for the prompt or the model, and the context is a third thing to check and the cheapest of the three.
The same shape, on another input. Adding a field to a judge's reply — the same text, one more thing asked for — loosened the judge, and moving the field after the score did not undo it. Here the judge was given more; there it was asked for more. In both, a change to what the judge is given that has nothing to do with the truth of the output moved what it does. Two measurements, one shape, and neither explains the other: that post names no mechanism either, and says so.
7.11 Test plan¶
Beyond a failing case per rule, which the conventions already require:
Abstention is unreachable by malformation. A reply with no score, a reply
with "score": null, and a reply with "abstain": false each stay an error and
none produces an abstention. This is §7.5's line as a test, and it is the one
that must never be weakened.
A declared abstention is one. {"abstain": true, "reason": "…"} produces an
errored verdict whose reason is the judge's sentence, for LlmRubric and for
Faithfulness.
An abstention with no reason is refused as broken, not accepted as an abstention.
Both at once is an abstention. A reply carrying abstain and a score does
not score.
The judge that never abstains is byte-identical. An existing suite's run document is unchanged across this release — the compatibility line, asserted on the document rather than on a message.
The call is counted. An abstaining judge raises calls, spent_usd and
tokens, and the run's judge line bills it (ADR 0025 §4).
Faithfulness distinguishes the two zeros. A judge that returns
total == 0 and one that abstains produce different sentences, and the first
says the judge counted.
The fold keeps both. A Repeated check whose samples abstain twice and
raise once reports both causes with their counts.
The report prints it and explain does not. Asserted in both directions, so
the deliberate absence in explain cannot be closed by accident later.
Not decided here¶
The arguments a tool was called with. Left out above, and left out on the two grounds that would each be enough: three providers disagree about the shape, and nothing on day one would read them. 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.
Whether pause_turn deserves its own value. It maps to other today,
which is honest and unhelpful: a paused turn is one to be continued, not one
that ended oddly. It gets a value the day a suite runs long-running turns, and
finish_raw carries the word in the meantime.
Whether the judging cost belongs in Run, in compare() and in the report —
§3. Whether a judge should be async: AsyncJudge is declared in the core
and used by nothing, and the online driver will not be able to score a stream
with a blocking judge. JudgeBase is deliberately shaped so that the async
variant is one more _complete rather than a second hierarchy, but which of the
two protocols the online driver gets is ADR territory for digline.online.