Note on the name (2026-08-26). «prompteval» was the project's working name; it became digline because the name was already taken in the category. The text below has been updated to the new name: this note is the only place where the old name remains written.
- Status: accepted
- Shipped: 0.1.0
- Date: 2026-08-25
- Introduces: fixed decisions 8 (tenant) and 9 (payload/verdict) of
CLAUDE.md - Assumes: ADR 0001
- Amended: 2026-10-06, by ADR 0023 at its acceptance —
§5's recipe for the generated
case_id, with a dated paragraph at the end of §5; the text above it is kept as written. The id stays generated, with no parameter to pass one in. Where one person elects from their own log it is a hash of the input; at a page served at the data owner's side the id minted inside is random (ADR 0037 §8). And two notes in Consequences that its acceptance makes false as written: the bridge bullet's "A disagreement this leaves open", which the rewritten Amends line closes, and the planned packages' promise that "a generatedcase_id" is rewritten at acceptance, whose words stand. The bridge bullet itself needed no amendment from it: it was corrected on 2026-09-27 on grounds of its own - Amended: 2026-10-17, by ADR 0052
— §9's "What resolves inside the suite's directory is also read from
source" holds for a name loaded the first time in a process, and not for
one already in
sys.modules. A dated note at the end of that paragraph block says so; the text above it is kept as written
Context¶
ADR 0001 was written assuming a single world: a developer evaluating their own prompts in their own repository. The assumption was wrong, and not at the margins.
digline's typical customer is not a single team. It is a software house that builds and maintains AI solutions for end companies that do not develop software. Three distinct roles follow, with different interests in and rights over the same data:
World 1 — the developer. Works in the repository, writes the assertions, reads the code. Sees everything they produce, because they produce it on data they chose.
World 2 — the software house. Maintains N customers at once. Must be able to answer "did customer A's solution get worse after Tuesday's release?" without holding customer A's production data, and without A's and B's data ever ending up in the same place by inattention.
World 3 — the end company. Owns the production data and does not read code. Is entitled to an understandable verdict about a system it cannot inspect, and is entitled to its data not leaving.
The conflict is real and cannot be solved with a permission: the software house needs the signal, the end company cannot hand over the content. The rest of this document exists to separate the two so that the type guarantees it, not discipline.
Decision¶
1. The tenant is the perimeter, and it is mandatory¶
Run.tenant: str, mandatory, non-empty. A tenant is a perimeter — an end customer of the
software house, a team's project. Not a label: a boundary.
compare()raises if the twoRuns have different tenants.promote_baselineraises if the run's tenant is not the one it is addressed with.- The on-disk layout is
.digline/<tenant>/baselines/<suite>.json, and tenant and suite names are validated as single path segments:..and/are refused.
The tenant is a directory and not only a field inside the file, because that way the separation is enforced by the filesystem instead of being described by a document. A software house with twelve customers must not be one typo away from reading one's history as another's.
Corrected 2026-09-26, after measuring what the layout enforces. The paragraph above says
the separation is enforced by the filesystem. What the filesystem enforces is
addressing, and the two are different claims: the directory makes filing or reading one
client's history as another's a refused mistake, not an impossible act. Everything the
paragraph's own justification asks for lives in that first claim, and it holds — a tenant
name is validated as one path segment, so .. and / cannot climb out; a symlink leading
out of the store is refused; and read_run and read_baseline raise TenantMismatchError
when a document's declared tenant differs from the directory it was addressed through. The
typo is caught.
What the sentence claimed beyond that, and no longer does: access. digline creates a
tenant's directories with whatever mode the process has and never sets or inspects one; it
reads no uid, calls no stat, and checks no credential. The only explicit modes anywhere are
two os.open creation flags on a register append and a journal leg, and neither is read back
— the O_EXCL on the second is the resume race, not privacy. One OS user who can read one
tenant's directory can read them all, and in a single repository every tenant's committed
baselines share one history, so a clone brings all of them. Separating access is the operator's
job — one repository per client, filesystem permissions, or separate hosts — and the
per-tenant directory is what makes each of those possible without restructuring anything.
That is what the directory is worth, and it is worth stating as what it is rather than as a
guarantee nothing here provides.
Where the perimeter is enforced, named because "the filesystem" was the wrong answer: in
the front ends, where the suite picks the tenant and the flag only verifies it —
_check_perimeter in the CLI and the same check in the MCP server, under the sentence "the
suite decides; this flag only verifies"; and in the operations that pair two runs, where
compare(), diff() and replay each raise rather than return a number about two perimeters,
and promotion inherits the refusal from the read_run it begins with — §8 of this record says
so, and it is why the bullet above naming promote_baseline describes where the condition
holds rather than where it is written. Not in the store's API, which takes a tenant as a string by design
and answers for whichever one it is handed.
And the one place a front end of ours crosses it, named rather than left to be found:
pytest-digline takes --digline-suite repeatably, collects every suite's rows into one
session, and prints every headline under a single digline separator — and nothing there
compares the suites' tenants, while headline() names neither the tenant nor the suite. Two
clients' sentences can arrive one after the other, under one exit status, with nothing saying
which client each is about. Recorded here as unruled: a record that describes a perimeter
may not be silent about the one place its own front end walks through it, and whether that is
refused or declared acceptable is not decided in this note.
Two more places make the same claim and are owed the same correction, named here so that
correcting one does not leave the others reading as measured: store/file_store.py's module
docstring — "the separation between perimeters is something the filesystem enforces rather
than something a document merely describes" — and ADR 0011 §8,
".digline/<tenant>/ puts the perimeter in the filesystem precisely so that it is not a field
in a document somebody could get wrong". Both are true of addressing and neither is corrected
by this note.
Corrected 2026-10-01 (#140). Both now say addressing, and so do three more that this list
did not name: tests/test_store.py's module docstring, ResultStore's class docstring in
store/protocol.py, and examples/operator/journal.py. "Two more places" was a count of
what one search found, not of the repository, and the last two were missed in two different
ways. protocol.py says "the filesystem then enforces", which no pattern of the sweep
matched; CONTRIBUTING.md's sweep bullet records it. journal.py was matched: replayed at
the commit that reported it (6ef90d4), ADR 0034 §13's sweep returns it for enforced by
the filesystem, and §13 lists three sites without it. Why it was dropped is not known.
The decision itself does not move. The tenant stays a directory, for the reason the paragraph above gives. What changes is the claim made for it.
Two runs from different perimeters produce numbers on the same scale: a wrong comparison would be arithmetically valid and factually meaningless. It is the kind of mistake that is never noticed, so it has to be made impossible.
SCHEMA_VERSION goes to 3: a file without a tenant cannot be placed in a perimeter, and
one without the redaction flag is indistinguishable from a complete document. Both must be
refused, not guessed.
1-bis. No sub-perimeter: the environment is a field, not a hierarchy¶
The tenant does not break down into sub-perimeters. Production, staging and acceptance for the same customer live in the same perimeter, because the perimeter is a property of the data: it is the same data, of the same owner, under the same contract. Treating staging as a perimeter of its own would suggest its data belongs less to someone, and it does not.
Run.environment: str is mandatory, non-empty, with no default, and does not enter the
on-disk layout: a suite has one baseline per tenant, not one per environment.
compare() reports it — Comparison.environment and Comparison.baseline_environment —
and constrains nothing. The reason is that comparing staging against the production
baseline is the pre-release check, that is, the thing the product exists for: making it
an error would have prevented the main use case in order to defend a symmetry nobody had
asked for. Reporting it serves the reader, who must be able to see what was compared with
what; enforcing it serves nobody.
No default, because an implicit environment is the premise of a comparison in which
nobody knows any more what they are looking at.
SCHEMA_VERSION goes to 4: a comparison that cannot say whether it read staging or
production is one nobody should act on.
2. The payload stays where it is born, the verdict travels¶
Extended by ADR 0003 (2026-08-26): a run also records the files that are the thing under test. They are neither verdict nor measurement, and they do not cross a boundary unless the suite declares
Disclosure(artifacts=True).
This is the rule that makes the three worlds compatible.
The verdict — name, assertion_id, status, score, threshold, tolerance, and the
metadata measured by an assertion — describes how a system behaved. It does not contain
the data it behaved on. It may cross a boundary.
The payload — the reason, and metadata not covered by a Disclosure — is, or
contains, what the system processed. It does not cross.
The reason is payload and not a label because a judge quotes what it judges: that is
its usefulness, and it is exactly what makes it unpublishable outside the perimeter.
Redaction is a function on the value. redact(run, disclosure) -> Run is the
primitive; run_to_json(run, redacted=True) is built on top, not the reverse. A redaction
living only in the serializer would be an opt-out that every future transport — the
Postgres store, an HTTP push, an export — would have to remember to switch on: the exact
shape of promptfoo's #9968 defect, where the beacon fired anyway. With the primitive on
the value, any transport redacts first and serializes afterwards.
In the document the payload fields are absent, not emptied: not even the length of the
original survives. The document carries "redacted": true at the top level, and
Run.redacted travels with the value, so no reader can mistake a redacted run for a
complete one in which the judge was terse.
The flag is verified, not believed. Run.__post_init__ refuses a run marked
redacted whose verdicts still carry a reason. Without the check, one could hand-build
a run that declares itself redacted with the contents intact, and the serializer would
omit the reasons on trust: the flag would announce a guarantee that nothing provides,
which is worse than no flag. It is the same family as the status that cannot contradict
its own threshold (ADR 0001).
The check covers the reasons and not the metadata: whether a metadata value should have
survived depends on the Disclosure that produced that run, and a Run does not carry it
along. Using redact(), the flag is correct by construction.
What survives is enough for compare(): an end company can send a verdict without
sending its own data, and the software house sees the regression.
3. Disclosure is asymmetric, and the asymmetry is the point¶
Disclosure(score_metadata: frozenset[str], run_metadata: frozenset[str])
Score.metadatais written only by assertions. Numbers and booleans travel on their own merit (travels()); strings only if the key is declared.Run.metadatais what an integration annotated from production. Nothing travels by default, numbers included.
The reason for the asymmetry: 0.01 written by CostBudget is a measurement; 1499.00
copied from a customer's request is their data dressed up as a measurement. Both are
float, but only the first has a provenance one can trust. "Numbers pass" is true of the
producer, not of the type.
The mapper has no route to Score.metadata: an assertion writes its own metadata from
what it measured, and what the mapper brings in through EvaluatorInputs.metadata does
not get there. It is structural, not a convention.
The allowlist is declared in the suite's code, never read from the data: widening what leaves a perimeter must be a change someone writes and a reviewer sees. The default is empty, so whoever redacts without knowing the suite's policy discloses less, never more.
4. Comparison inherits the payload of its inputs, and that is correct¶
compare() works between a redacted run and a complete baseline: everything it reads
survives redaction. But the Comparison it returns contains the verdicts it received,
so it carries the payload of what it was given.
This is not a defect to fix. Inside the end company's perimeter — where world 3's report
is generated — the comparison must carry the reason, because that is what makes a
failure understandable and actionable. An always-redacted Comparison would make the
report useless exactly where it is needed.
The operational consequence, which stands as a rule: a Comparison does not cross a
boundary. What crosses is a redacted Run, and the comparison is redone on the other
side. Whoever writes a transport for Comparison is going down the wrong road.
5. The case_id is not payload, and there is no way to copy one¶
Amendment owed by ADR 0023 (2026-09-28): that record amends this section, and its header rules that the edit waits for its acceptance. It is proposed, so nothing here has moved yet. What is owed is the bridge bullet's recipe and nothing else — the structural half does not wait, and world 1 is untouched. Consequences, the bridge bullet, says where the drafted text is parked: it is written, dated 2026-09-16, on the unmerged
capturebranch.Made 2026-10-06, at ADR 0023's acceptance: the amendment is the last paragraph of this section.
The case_id has to cross the boundary: it is the key compare() pairs on, and without
it there is no comparison. So it cannot be payload.
It follows that it must never contain payload, and the guarantee is structural:
- World 1. The developer chooses the
case_idand answers for it. It is their test data, chosen by them. - Production → repo bridge. The id is generated by digline — date, sequence number, short hash of the already redacted response — and there is no parameter to pass one in. It is not that the application's identifier must not be copied: there is no way to copy it.
The difference between the two formulations is the whole decision. A rule that can be skipped in a hurry will be skipped in a hurry; one that has no entry point does not need to be remembered.
What exactly is owed, since the note at the top of this section says only that something is. Added 2026-09-28, beside that note and not instead of it. Owed: the Production → repo bridge bullet's recipe — date, sequence number, short hash of the already redacted response — which ADR 0023 §6 refuses on both real histories and replaces with an id derived from the input. Not owed, and not waiting: the structural half this section is named for. The id is generated by digline, there is no parameter to pass one in, and world 1 is untouched — a developer still chooses their own ids and answers for them. A reader who takes the note above as putting the whole section in doubt has read it too widely, and the recipe is the only sentence in question.
Why the pointer and not the edit. ADR 0023 is proposed, and its own status line rules
that what it amends is edited at its acceptance and not when it lands: an amendment
reading as in force while the record that makes it is undecided would be a ruling and not a
landing. So the drafted text stays where it was written — on capture, dated 2026-09-16 —
and this section says it is coming rather than pretending it is here. Before 2026-09-28 it
said neither, and a section with no dated note reads as a section with nothing to
correct, which was the one thing this one could not claim: §1 carries its correction, and
so does the bridge bullet of Consequences, corrected twice on its own account (2026-09-26,
and again 2026-09-27 on pseudonymisation). This was the only one of the three that read as
settled.
Named and not counted, for the reason §8 of this record gives. Both notes above say the
bridge bullet where the first draft of each said the second bullet. It was second when
ADR 0023's Amends line named it that way, and ADR 0034 inserted a bullet above it at 16:29
on 2026-09-27 (3aeb53d), making it the third — without anybody touching the thing the
ordinal points at. This record has already paid for that twice, in one section: §8
stopped counting its conditions and started naming them — "that is the argument for naming
conditions rather than counting them, made by the correction of a count" — and §8 carries a
numbering note saying it is the eighth and not the sixth, because inserting §1-bis moved it.
An ordinal is a claim about the neighbours, and the neighbours are not yours.
Amended 2026-10-06 (ADR 0023 §6, accepted that day together with
ADR 0037
§8): the generated id stays generated, with no parameter to pass one in, and the recipe
changes. Date, sequence number and a hash of the response failed on both real histories: a
record may have no response, and a sequence number mints two ids for one item captured on two
branches. Where one person elects from their own log, the id is
"cap-" + sha256(canonical(vars))[:16], from the input; the application's own identifier is
kept in the case's metadata, inside the perimeter, and never enters the id. At a page served
at the data owner's side, the id minted inside is random, by
ADR 0036 §5's rule, and the id that
leaves is a token from the name table. World 1 is untouched: a developer still chooses their
own ids, and cases already committed keep theirs. The draft of 2026-09-16 on capture is not
what landed: it predates the served page, and gave the hash for every case.
6. Production store: Postgres, and retention is mandatory¶
Production data does not live in the repository. The repository is the system of record for the verdict; the production payload has a different volume, life cycle and owner.
First implementation: Postgres, inside the end company's perimeter. Decided now because the decision informs the protocol, not because it has to be written now. Reasons: it is the thing an end company already runs, it does not require a new service to get approved, and it holds up under the per-case and per-time-window queries the report needs. No dependency on a service run by us — not being able to exfiltrate is a selling point only if it is true for paying customers too.
Retention is mandatory, not freely configurable. A production store without a declared deletion policy is not compliant and digline must not allow creating one: the window is a mandatory constructor parameter, not a setting with a generous default. An archive that grows forever is an incident waiting to happen, and the convenient default is how you get there.
Amended 2026-09-27, by ADR 0034
§1, at its acceptance. The carve-out widens. This section put the production stream
outside the repository. It now also puts offline runs against the end company's own
cases there, because they have the same volume, life cycle and owner as the stream, which
is the line the first paragraph drew. So cases, runs, recorded responses, the journal and the
full reference live inside the end company's perimeter, addressed by the same key they have
today. The repository holds two things: the key, which links a commit to the reference it
was judged against, and a projection** of that reference (ADR 0034 §2). Fixed decision 2's
reason is untouched: nothing goes in a home directory, nothing becomes global state on a
machine, and the layout is still .digline/<tenant>/. What changes is the perimeter that
directory sits in.
Two things in this section do not extend to the widened part, so that nobody reads them as inherited: - Postgres is this section's choice for the stream. It says nothing about the form of the offline store, and ADR 0034 leaves files or a database open. - Mandatory retention belongs to a store whose purpose is a stream. A reference that can expire is a reference that can vanish from under a gate, so the offline store takes ADR 0034 §14's contract for forgetting instead.
7. A single way in: EvaluatorInputs via a mapper¶
Whatever is evaluated — an offline matrix, a production stream, a single response —
enters the core only as EvaluatorInputs, built by a mapper. The core does not know
what a trace, a matrix or a stream is.
It is the boundary that makes the three worlds the same engine, and it has to be defended:
if one day the driver needed to know EvaluatorInputs in a way other than "a mapper hands
it to me", that is the sign the boundary has broken.
How production data reaches the mapper — in-process or over OTLP — is deferred to a later ADR. It is a choice with heavy operational consequences for the end company (an OTLP receiver is one more service to run; in-process is a dependency inside their application) and must not be taken by inertia. All that is recorded here is that the core does not prejudge it.
8. Promotion's conditions¶
promote_baseline refuses whenever the run it is handed is not a reference. The
conditions are collected here because they are one rule seen from several sides:
a baseline is an approved reference, and each condition prevents approving something
that is not one. Each exists because violating it produces a comparison that runs anyway
and returns numbers anyway — the worst way to be wrong.
Amended 2026-09-22. This section said three — in its heading, and twice in the
paragraph above — while promote_baseline refused on more than three. Conditions were
added after this record was written, each by the record that decided it, and none came
back to say so here. Every one of them is now named below, and the count is gone from the
heading and from the paragraph rather than corrected: a section that lists its conditions
underneath itself does not need to say how many, and saying so is what let this one be
wrong in its own title for two releases. (ADR 0012 §3 and digline.wire.contract state
the general form of that rule — no total, and no enumeration where the thing can be found
by looking. A record's conditions cannot be found by looking, which is why they are
enumerated here; what must not be written beside the enumeration is a number.)
Amended again the same day, and the second pass is the instructive one: the first one
corrected three to five, named five, and still missed one. unreconciled (6 below)
was the most recently added and the easiest to miss, because it is the only condition that
does not bring an exception type of its own — it raises ErroredRunError, like 3 does.
Anyone counting exception types finds five and stops. That is the argument for naming
conditions rather than counting them, made by the correction of a count.
Where each is enforced, since it is not all one function, and three places because they
answer three different questions (store/promotion.py's docstring gives the reasons).
1 and 7 belong to the reading: a run addressed through the wrong tenant, or one whose
document declares another suite, is refused by read_run, which promote_baseline calls
first, so the two hold on promotion without appearing in it. 2 to 6 answer from the
document alone: they are refusals_for, which returns them in the order 2, 4, 6, 3, 5
and which every store calls rather than restating them. 8 belongs to the write: each
backend checks it beside its own write, last. They are all listed here because they are
conditions on promoting, and a reader who went looking for all of them in
promote_baseline would not find them.
Corrected 2026-10-05 (#437), and the error was about where, not only how many. The
paragraph above said 2 to 6 "are raised by promote_baseline itself" and named 1 as the
one exception. That was false from 2026-09-28, when a5ee760 moved 2 to 6 into
refusals_for. And 1 had not been the only exception since 2026-09-24: 7 is a second
refusal of read_run, and 8 is checked in each backend. 7 and 8 were also missing from
the list below. Both arrived on 2026-09-24, and neither came back to add itself here. That
is the cause the first amendment of 2026-09-22 named and left standing: it took the count
out of the heading, and nothing held the list to the code. Since this correction,
tests/test_promotion_list.py holds the list below equal, number for number and type for
type, to the one in ResultStore.promote_baseline's docstring. That copy sits beside the
code, and it is the one 7 and 8 did reach. The test also requires every type in
PromotionRefusal, and the type refusal_for_a_moved_baseline returns, to appear in the
list. What the test cannot see is stated in its own docstring.
TenantMismatchError— the run's tenant does not match the one it is addressed with. A perimeter is not crossed because of a wrong copy (§1).ConfigMismatchError— theconfig_hashdoes not match the configuration in force. Otherwise the baseline would record scores obtained under thresholds different from the current ones (ADR 0001 §5).ErroredRunError— one or more verdicts are inerror, and the exception lists the cases. An error means the suite could not judge: there is nothing to measure a future run against. Promoting it would freeze a permanent red row that no reader could tell apart from a new failure — and the reader, in world 3, does not have the code to work it out for themselves.
The remedy for an unstable case is to fix it or take it out of the suite, not to
enshrine it. It is the natural sequel to ADR 0001's rule that error is not green and
is not a regression: it is not a reference either.
Read as written since 2026-10-02 (#374). "One or more verdicts" was implemented as
case verdicts only, so a run whose only error was a run-level figure — an aggregate
over an empty denominator, say — was promoted. A run-level verdict is a verdict: the
refusal now names it beside the cases, as a run-level check. The exit code still
counts cases only, which is ADR 0010 §8's ruling and is not changed here.
ReplayedRunError— the run declaresrejudged_from. The answers must have been measured, or the interval promoted with them was measured without the target in it. Added by ADR 0015 §7, which calls it "the fourth condition on promotion" and sits it "with the other three" — while its ownAssumes:line, 255 lines up in the same file, still cited this section as "promotion has three conditions".-
UncalibratedRunError— a calibration case scored outside its declared band. The numbers exist and are not measurements. Added by ADR 0024 §4.5, which calls it "the fifth condition, beside tenant, configuration, errored". -
ErroredRunError, for a run that does not reconcile — the run recorded a gap between what its suite asked and what came back, so what it measured is not known. It is raised before 3, because it is the stronger statement about the same document: 3 says a verdict errored, this says the record is incomplete, and the refusal names the case and the check rather than only the case. Added by ADR 0027 §3, which states the refusal and the reason — "a reference nobody can say that of is no reference" — and did not come back to add itself here. It shares an exception type with 3, which is why the amendment above found five conditions by counting types and missed this one. -
SuiteMismatchError— the stored run declares a suite other than the one it was addressed through. A document that contradicts itself is refused, not filed where it claims to belong. Raised byread_run, beside 1. Added on 2026-09-24 byb3735a5, shipped in 0.19.2, with no record of its own. It reachedResultStore.promote_baseline's list and not this one. Brought here on 2026-10-05 (#437). BaselineMovedError— the baseline present now is not the one the run was compared against, which the person promoting names. Promoting would replace a reference nobody compared the run against: the lost update. Checked last, because every refusal above is about the run and holds whatever the reference, and beside the write, which is as narrow as the window gets without a lock. Added by ADR 0031, accepted 2026-09-24 and shipped in 0.20.0: §1 for the key the person supplies, §4 for the type. Like 6, it did not come back to add itself here. Brought here on 2026-10-05 (#437).CrossingRefusedError— the run records an artifact under.diglineor.git, in any case and at any depth, or one keyed outside the perimeter. A baseline carries every artifact's full text intobaselines/, which is versioned, so promotion is an exit and this holds whatever theDisclosure. Answered byrefusals_for, first. Added by ADR 0042 §3, and brought here by the change that implements it.NothingJudgedError— the run judged none of the cases that count, as ADR 0049 §3 defines them, read against the suite's declaration: every case suspended, errored or absent, whether or not the suite declares an aggregate. A reference is a measurement, and a run that measured nothing cannot be one. Answered byrefusals_for, before 3, from the declaration the caller hands in beside the configuration hash. Before 3, so the reader is never sent to an aggregate, which is always the symptom. Where the cases errored the case is the cause, and an order cannot say that, so the refusal does: it names each case that counts with its state, suspended, could not be judged or absent, and carries 3's remedy for the ones that could not be judged. Added by ADR 0049 §5, and brought here by the change that implements it.
Proposed 2026-10-13: a tenth condition, NothingJudgedError, for a run that judged
nothing as ADR 0049 §3 defines it, whether or not the suite declares an
aggregate (ADR 0049 §5).
It joins this list with the change that implements it, as 9 did.
Numbering note: this section is the eighth, not the sixth, because inserting §1-bis
shifted the numbering after the initial draft. §6 remains the case_id.
9. The suite is compiled from source, and git is read before importing it¶
The suite is Python code, not data (§7 and the decision on the configuration surface: a
Judge is an object, a Target a function, a Disclosure is declared in code by
construction). Two constraints on how the CLI loads it follow, non-negotiable, both of
which emerged running the CLI on a real repository and neither of which is visible
in-process.
The suite is compiled from source, without reading or writing bytecode. It does not go through the ordinary import machinery.
- Writing bytecode leaves
__pycache__in the user's repository, and a repository with new untracked files is dirty: our own import would have made every run declare itself non-reproducible. A defect digline attributed to the user and was its own. - Reading bytecode can execute a stale suite. Python's freshness check is
(mtime, size)with one-second granularity: a file modified within the same second and at the same length is served from the cache. It is the only defect met in the whole project that could have let a test meant to fail pass — and for a tool whose premise is reproducibility, the only acceptable answer to "what was executed" is "what is on disk".
The suite's directory goes on sys.path before execution. A suite imports the
application it evaluates: from brief import judge is the normal case, not the exception.
Without this, the first real suite fails on its first line with ModuleNotFoundError, and
the tool looks broken because it is. It is what python file.py and pytest from its own
rootdir do. It holds only for a spec that is a path: a package.module:attr is
already importable, and widening the path for it would mean reaching into the caller's
environment for no reason. The insertion is guarded against duplicates.
What resolves inside the suite's directory is also read from source, through a
FileFinder built with a SourceOnlyLoader — a derived SourceFileLoader that overrides
get_code to always compile from disk — registered in sys.path_hooks and in
sys.path_importer_cache for that one directory only. On top of that,
sys.dont_write_bytecode is true for the whole CLI process: we leave no debris.
The previous constraint covered the suite file; it did not cover what the suite imports,
that is, the user's application — the only thing that really changes between two runs.
The concrete case, reproduced and now in tests/test_cli.py: a brief.py next to the
suite, modified at the same length and within the same second, with a __pycache__
already left by an ordinary python -c "import brief". The resulting .pyc looked fresh
to Python's (mtime, size) check, the application ran in its old version, and compare
answered "nothing got worse" while something had. The test was verified by disabling
the finder: without it, it fails.
The scope is a single directory, deliberately. A loader for every module would slow down every import in order to protect files that do not change during an evaluation — stdlib and site-packages — and would reach far beyond what this tool has the right to alter. Like the other two, it holds only for a spec that is a path.
Noted 2026-10-17 (ADR 0052),
the paragraphs above kept as written. The finder is reached only for a name that is not
already in sys.modules. In a process that loads a suite more than once, the first load
of a name decided what every later load got, whatever was on disk by then: measured
through digline-mcp, where a second run after an edit answered with the old
application and compare exited 0 (#513). ADR 0052 decides that what digline put in
place for a suite's scope, its modules and its entries on sys.path and beside it, is
taken out before the next load, and that a name already loaded from elsewhere is named.
Git status is read before any import of ours. Otherwise the answer would depend on
what our loading had just done rather than on the state the user left the repository in.
The -dirty marker describes the user, not us.
A corollary recorded here because it is of the same family: created_at keeps the
microseconds. Truncating it to the second was an aesthetic choice, until listing the runs
showed the cost — a run's key derives from the timestamp and the configuration, so two
runs in the same second on the same suite produced the same key and the second silently
replaced the first.
10. The aggregate, and the one number you improve by doing less¶
Run.aggregate carries verdicts on the run — precision, recall, accuracy — alongside
those on the cases. They are Verdicts like any other: mandatory threshold, hence a gate
by construction, and compare() says whether they regressed. No new mechanism.
The reason is measured, not aesthetic. Four executions of one unchanged prompt agreed with
the human marking on 14, 14, 15, 15 cases out of 21, while individual cases jumped by
three votes. 15/21 − 14/21 = 0.0476: within a tolerance of one case. The aggregate is
the gate, the per-case is the diagnosis.
Three constraints, each for a real path to error:
overnames exactly one assertion. A missing name aggregates over an empty set; an ambiguous name — twocontainsin the same suite are the ordinary case, and it is the reasoncompare()pairs by identity and not by name — aggregates over whichever comes first. They are the same mistake seen from two sides, andSuiterefuses both, listing the candidates.- Empty denominator →
error. If the system kept nothing, precision is not1.0: it is undefined.1.0would be the most dangerous possible answer. - Mandatory labels if an aggregate counts a confusion matrix. No implicit denominators.
The note that must outlive this document. suspended_excluded is the only number in
the product that can be improved by removing work instead of doing it better:
suspending a failing case raises the ratio without anyone lying. We do not forbid it — an
unstable case should be suspended, that is the reason suspension exists — but the two
exclusions travel next to the figure wherever it goes: in the reason, in the metadata,
in the report's table. That number is never read on its own.
And where to put the threshold. An aggregate is a contract about present
behaviour, not a target: the threshold is set where the system is — measured — and the
comparison protects against getting worse. A threshold set where you wish you were makes
the gate red by construction, hence useless for CI and soon ignored by whoever reads it.
With the numbers from the brief: measured precision ≈ 0.62, hence a threshold of 0.60,
not 0.70. You reach 0.70 by improving the system and then raising the threshold, which is
a change to the configuration — visible in config_hash and in a pull request.
SCHEMA_VERSION goes to 6.
Consequences¶
compare()andpromote_baselinehave one more error path, and it is right that it is an exception and not a return value: crossing a perimeter is not an outcome.- A committed reference may be a projection of one. Added 2026-09-27, by
ADR 0034 §2, at its
acceptance. Where the store lives inside the end company's perimeter (§6 as amended),
the promotion happens there. The file the repository commits is then derived from the
reference
promote_baselinereturned: promote first, then project. Every condition in §8 is unchanged. None is re-implemented and none is relaxed, because the projection is made from a promotion that has already passed them. What §8 approves is still the reference, and the committed file is its derivative. - The production → repo bridge is the point where anonymization is mandatory: it is the only place where payload and verdict touch, and the only one where a mistake is irreversible — once committed, it is in git's history.
Corrected 2026-09-26: the requirement as written above cannot be met, and this note says so and stops there. Anonymising the input destroys the case. The input is what a judge judges, so a case whose input has been anonymised is not the same case weakened — it is a different case, and the verdict recorded against it answers a question nobody asked. The choice the material leaves is the text or no case, which is not a choice "mandatory anonymization" admits. The second half of the bullet is untouched and remains true: this is the one place payload and verdict touch, and once committed a mistake is in git's history. What is wrong is only the requirement, and it has been wrong since this record was accepted.
What this note does not do, deliberately. It does not say what should replace the requirement. ADR 0023 §8 has a remedy — the regime becomes declared rather than mandatory — and taking it here would be that record's amendment made by another hand while it is still proposed, which is what its own header refuses: an amendment reading as in force while the record that makes it is undecided would be a ruling and not a landing. So the problem is stated and the remedy is left where it was decided, unaccepted.
Why now rather than at 0023's acceptance. ADR 0023 promised two edits to this record —
§5's generated-id recipe and this bullet. The first was written: it sits, dated
2026-09-16, on the unmerged capture branch, and was deliberately not carried when 0023
landed on main as proposed. This one was never drafted at all, on any branch. So the
two are not in the same state, and only one of them is parked: waiting for 0023 protects a
text that exists, and for this bullet it protected nothing while a fixed-section record went
on stating a requirement its own successor had already shown could not be met. A record that
cannot be met should say so in its own voice, which needs no other record's status.
Corrected again 2026-09-27: what replaces the requirement. The first note stated the problem and left the remedy to ADR 0023, which is still proposed. The remedy does not need that record. It stands on the ground the first note already stands on: the input is what a judge judges. Anybody can check that without reading ADR 0023, and so this record now says the remedy in its own voice.
What the bullet said: the production → repo bridge is the point where anonymization is mandatory.
What it says now: the bridge's regime is pseudonymisation, with the mapping held by the data owner. - What the bridge writes into the repository carries structural identifiers and no text, so it identifies nobody on its own. - The mapping from an identifier back to its case exists, is kept separately from what is committed, and is held by the end company inside its own perimeter.
Why not anonymisation: anonymisation means that no such mapping exists anywhere. A case cut from its link and stripped of its text is not a case, which is the first note's point.
Pseudonymised data is still personal data. This does not take data-protection law off the bridge, and nothing here claims it does. What it buys is narrower, and it is the whole claim: the committed record alone identifies nobody.
What still stands: the rest of the bullet. This is the one place payload and verdict touch, and once committed a mistake is in git's history. Under this regime that mistake has a sharper description: any string in the committed record that is not a structural identifier.
What this note supersedes: the paragraph "What this note does not do, deliberately" above, which left the requirement with no replacement until ADR 0023 was accepted. It is kept, because it was right on 2026-09-26, and a reader who cites it should meet this.
A disagreement this leaves open, named here and not resolved. ADR 0023 is proposed.
Its Amends line says this bullet "becomes a declared regime", and its Touches line says
CLAUDE.md's bridge/ line follows its §8. From today this bullet says something else, so
the two records visibly disagree. Rewriting ADR 0023's header waits for that record's
acceptance, which is where its own status line says its amendments are decided. Until then,
where they disagree about this bullet, this record is in force: ADR 0023 is proposed, and by
its own rule what it amends waits for its acceptance.
Amended 2026-10-06 by ADR 0023, at its acceptance: the disagreement is closed. ADR
0023's Amends line now says this bullet was amended here on 2026-09-27, on the ground its
§8 gives and not on its authority, so it amends nothing about the regime; and its Touches
line says CLAUDE.md's bridge/ line was corrected and does not wait on it.
- Three planned packages, in the order they will be built after the offline driver:
digline.report (the document for world 3), digline.production (the Postgres store
with mandatory retention), digline.bridge (production → repo, with anonymization and a
generated case_id).
Corrected 2026-09-27. "With anonymization" is now: with pseudonymisation, the mapping held by the data owner. Anonymization cannot be met, because the input is what a judge judges. Pseudonymised data is still personal data. The Consequences bullet above gives the correction in full; this is the same correction, at the second place the word was written.
Owed to ADR 0023, 2026-10-01. "A generated case_id" is superseded in substance by
ADR 0023 §6, which amends this record's §5 with the identifier's
recipe. ADR 0023 is proposed, and by its own rule what it amends waits for its
acceptance, so these words stand until then and are rewritten when it is accepted.
Amended 2026-10-06 by ADR 0023, at its acceptance: the words stand. The id is still generated,
with no parameter to pass one in. What changed is the recipe, which §5's dated paragraph
gives.
- The build order is deliberate: nothing online before the report. The report is what
world 3 sees, and it is the only one of the three artifacts that today exists in none of
the audited competitors.
Not decided here¶
- The production → mapper transport (in-process or OTLP), as above.
- The form of the report: whether it is HTML, PDF or both, and how much of the
Comparisona non-technical reader should see. - Which
environmentvalues are canonical. The string is free on purpose: end companies name their own environments as they like, and imposing an enumeration would have produced anotherthat half the real world falls into. - Whether the unreconciled condition earns an exception type of its own. Conditions 3
and 6 of §8 both raise
ErroredRunError, so a caller that catches it cannot tell which of the two fired: a verdict errored and the record is incomplete arrive under one name, and they ask for different things — the first says re-run or fix the case, the second says the run does not know what it measured. The refusal messages differ and name the case and the check, so a person reading stderr can tell; a program catching the type cannot. Two facts under one name, in the exception hierarchy this time. Recorded as a finding rather than fixed, because a new exception type is a change to what every caller may catch — it belongs to whoever decides the store's error surface, not to a cleanup, and theTRANSLATEDtable indigline-mcpwould move with it. (Found 2026-09-22, while correcting §8's count for the second time — the shared type is why the count was wrong: five is what you get by counting exception types.)