ADR 0004 — Every plugin is a target and a judge¶
- Status: accepted
- Date: 2026-08-28
- 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.
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.
Not decided here¶
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.