- Status: proposed 2026-10-17. The decision below was ruled on 2026-10-17, on #513, before this record was written. The text is not yet accepted, and nothing in it is implemented. #513 stays open: this record answers its question, and the issue closes when the code lands
- Shipped: unreleased
- Date: 2026-10-17
- Opens: nothing on landing. No code, no
SCHEMA_VERSION, noOUTPUT_VERSION. The code that implements it opens an additive schema bump, with its step in_STEPS, formodules_uncheckedon the run (The field on the run, below). The number is not written here: another branch may raise the version before that code lands - Amends: the guarantee in the docstring of
digline/host/loader.py, that "everything that resolves in that directory" is read "through a finder scoped to it alone — because the application under test is what changes between two runs, and a stale copy of it hides the very regression the tool exists to find"; and ADR 0002 §9, "What resolves inside the suite's directory is also read from source", which carries a dated note pointing here and is kept as written. No other public page promises it: a search ofREADME.md,AGENTS.md,docs/*.md, the packages' READMEs and the plugin's pages, at415f773, found none - Assumes: ADR 0002 §9 (the suite and what resolves in its directory are read from source, for a spec that is a path); ADR 0029 §7 (an outcome that would go red on a healthy first comparison teaches that the feature is noise), §8 (a control that could not run is counted, not passed, and moves no number) and §9 (the run being judged declares)
- Touches: nothing in
CLAUDE.md's fixed section.CLAUDE.md's fixed decision 3 — no vacuously green assertion — is the reason: a run measured on an application that is not the one on disk is a green nobody measured - Number: 0052. Swept on 2026-10-17, before a line was written, across
origin/main(415f773), every local and remote branch and tag (83 refs, after a fetch with--prune), the worktrees of this repository and the open pull requests (#586, open at the time of the sweep, which adds no record). The highest number taken anywhere is 0051
Context¶
digline#513.
The mechanism, read in the code at 415f773. load_suite compiles the
suite's own file from source into a new module every time it is called
(_import, digline/host/loader.py). The modules that file imports do not
go through that path. import brief goes through Python's own import, which
answers from sys.modules before it consults sys.path or any finder. The
finder that scopes the suite's directory to source is reached only for a name
that is not already in sys.modules. So within one process, the first load
of a name decides what every later load gets, whatever is on disk by then.
A process that loads one suite and exits does not meet this. digline run
is such a process. Three are not:
digline-mcpserves many tool calls in one long-lived process, and each callsload_suiteagain (opened,server.py:185,load_suiteat:195at415f773). Theruntool calls the target (:373) and writes the run.pytest-diglineloads every suite a session names, one after another, in pytest's process (plugin.py:472), and calls each target under--digline-run(plugin.py:526). Read in the code, not measured.- Within one
diglinecommand,--target <file>loads a second file through the same_import(loader.py,load_target). Read in the code, not measured, and outside this record.
Measured through the real server, at 415f773, Python 3.13.11, macOS,
digline-mcp started as python -m digline_mcp --root <repo> and driven over
stdio by the mcp client 2.2.0. Each suite recorded its responses
(record_responses=True), so the store holds what the target answered.
(a) One suite, its application edited on disk between two run calls of one
session. The suite's check was Contains(needle="answer v1").
| step | what happened |
|---|---|
run |
the target answered answer v1 to c1; the run was promoted, with the CLI, in its own process |
brief.py edited on disk to answer answer v2 |
|
run, same session |
the target answered answer v1 to c1, and the store recorded that answer, in a run that declares git_commit = 1d811f4…-dirty: the edited tree, which the target did not execute |
compare through the server |
exit_code 0, "Nothing got worse compared with the reference." |
control: digline run then digline compare, each a fresh process, same disk |
the target answered answer v2 to c1; exit 1, "1 check got worse compared with the reference." |
(b) Two suites in one session, one/ and two/, each importing a module
named brief from its own directory. Each suite's check was
Contains(needle="answer").
| step | what happened |
|---|---|
run one/suite.py |
the target answered answer from one to c1 |
run two/suite.py, same session |
the target answered answer from one to c1, and two's store recorded it: the answer of one/brief.py |
control: digline run --suite two/suite.py, a fresh process |
the target answered answer from two to c1 |
The green in (b) is the silent case, not the lucky one. Contains(needle=
"answer") passes on both answers, so two's run is green whichever
application answered it. With a needle that told the two apart, two's check
would have failed on one's answer: deduced from the check and the recorded
answer, not run. A suite whose checks cannot tell its application from
another's is the ordinary case, and there nothing at all shows.
(c) Two suites in one session, where the second imports a name that exists
only in the first one's directory. first/ holds helper.py, which first's
suite never imports, so it is never in sys.modules; second/suite.py imports
helper. Measured the same way, at d8df3aa, where digline/host/loader.py
is unchanged from 415f773.
| step | what happened |
|---|---|
run first/suite.py |
the target answered answer from first |
run second/suite.py, same session |
the import succeeded: second's target answered answer from first/helper.py, and second's store recorded it |
control: run second/suite.py alone, a fresh session |
refused: "could not import 'helper': No module named 'helper'" |
control: digline run --suite second/suite.py, a fresh process |
the same refusal, exit 64 |
Here it is first/ left on sys.path, not sys.modules, that answered: an
import that fails on its own succeeded, and a refusal became a measurement.
Not measured: the reverse order in (b); an application that is a package
rather than one module; Python versions other than 3.13.11; pytest-digline
and --target.
What load_suite changes in the process, read in digline/host/loader.py
at 415f773, and what this record decides for each:
| state | where | restored today | after this record |
|---|---|---|---|
the suite's own module, sys.modules["_digline_suite_<stem>"] |
:170, :214 |
never; the next suite with the same file name replaces it | removed |
every module the suite imports that resolves in its directory, in sys.modules |
Python's import | never | removed |
the suite's directory, at the front of sys.path |
:206 |
never | removed |
a path hook for that directory, at the front of sys.path_hooks |
:134 |
never | removed |
a finder for that directory, in sys.path_importer_cache |
:137 |
never | removed |
the directory, in the module-level set _scoped |
:104, :126 |
never | removed |
A spec that is a dotted module (package.module:attr) goes through
importlib.import_module (:258): it is outside the scope ADR 0002 §9 draws,
and outside this record.
The decision¶
Ruled 2026-10-17. Two facts, a rule for where the second is read, and two costs declared.
-
What digline put in place for a suite's scope is taken out before the next load. The accounting is digline's: it keeps what it inserted while it loaded a suite, and before it loads a suite again in the same process it removes exactly that. Nothing it did not insert is touched: no name from the standard library or a third party, only what it put there for the suite's scope. That is every row of the table above:
-
the modules the suite imported that resolved in its directory, and the suite's own module, so that the next load reads them from disk;
- the directory on
sys.path. Left in place, it would sit behind the next suite's directory, and a name the next suite imports that exists only in the earlier suite's directory would be found there, where otherwise the import would have failed. That turns an error into a wrong measurement, the same class of defect as #513, and (c) measured it; -
the path hook, the importer cache entry and
_scoped. These are taken out for symmetry, not because they were found harmful. Each is keyed on one directory and holds no module, so today they do no harm; but that is true of today's code and would have to be checked again at every change, where taking out everything that was put in does not. -
A name already loaded from somewhere digline did not load it is named, not refused:
modules_unchecked. A name that would resolve inside the suite's scope and is already insys.modulesfrom an origin digline did not load stays as it is, and the run says so: the names and their count, on the wire and in the document, moving no number, in the form ofpinned_unchecked.
Why it is named. The authority is ADR 0029 §8: "A control that quietly reports success when it could not run is worse than no control, because it produces the same green as a control that ran." Reading from source is that control here. Where a name was loaded before digline could read it, the control did not run for that name, and a run that said nothing would read the same as one where it did.
The precedent for its form is the comment attached to
Headline.pinned_unchecked, report/render.py:221-223 at 415f773: "a
control that reports nothing when it could not run produces the same green
as one that ran. So it is named in the sentence, on the wire and in the
document". The two citations do different work: §8 is why it is said, the
comment is how a fact of this kind is said.
Why it is not refused. Under pytest, a test module and a suite that
import the same application module is the normal arrangement, not a
mistake: the tests import brief to test it, and the suite imports brief
to evaluate it. A refusal there would arrive red on a healthy repository,
on the first session that names a suite. That is ADR 0029 §7's argument
against firing on new: the first comparison after a pin is added "would
go red on arrival, teaching the author that the feature is noise before it
has ever caught anything." digline cannot unload what it did not load
without reaching into state that belongs to the process it runs in, so what
it can do is say it.
- The rule:
modules_uncheckedis read in the comparison's headline too, from the run being judged only. The comparison is where the exit code is read, and a verdict built on a measurement with a declared gap cannot be silent there: ADR 0029 §8, applied one level up. Only the judged run is read, as pins are read from the judged run alone (core/compare.py:1812at415f773,pinned = set(run.pinned)) and as ADR 0029 §9 rules that the run being judged declares. What is read is its count, or the fact that its value is not recorded, and that fact is said too (Its price, from its third paragraph). Neither moves a number, aspinned_uncheckedmoves none.
The name. modules_unchecked follows pinned_unchecked. Three candidates
were weighed, at 415f773:
| candidate | follows | ruling |
|---|---|---|
modules_unchecked |
pinned_unchecked, core/compare.py:422-423: "The pinned paths this comparison could not answer for"; report/render.py:206: "How many pinned paths this comparison could not answer for." |
Chosen. The same shape: a control that could not run, counted, moving no number. |
modules_unreconciled |
unreconciled, report/render.py:280-282: "a run that does not reconcile does not know what it measured" |
Set aside: unreconciled moves the exit code, through the errored verdict each gap is (render.py:283-284), and this fact moves none. |
modules_echoed |
target_echoed, report/render.py:242-248: "an absence disguised as a presence, stated and never diagnosed… It moves no exit code." |
Set aside: the module is there. It is not an absence disguised as a presence; it is a presence nobody read. |
What does not change¶
- A process that loads one suite.
digline run, and every otherdiglinecommand, loads one suite and exits; nothing here changes what it measures. - The scope. ADR 0002 §9's one directory, for a spec that is a path. A dotted spec, the standard library and third-party modules stay under the ordinary machinery.
- Reading from source. What is loaded again is still read through the
scoped finder and the
SourceOnlyLoader.
The field on the run, checked against ADR 0014 §1¶
modules_unchecked is recorded on the run, so that a stored run's document
says it and a comparison can read it (decision 3). ADR 0014 §1 lets a field
ride a schema bump only if three conditions hold, "checked in the ADR that
proposes the field, and the ADR says so in its own words". Here they are.
-
It leaves
config_hashuntouched. Two runs of one identical configuration can carry different values: one in a freshdiglineprocess, where nothing was loaded before the suite, and one as the second call in adigline-mcpsession, where something was. A field that varies while the configuration does not cannot be part of the configuration's identity without renaming stored runs, and unpromoting baselines, for a reason that is not a change of rule. -
It migrates additively, with the value not recorded. A document written before this field records nothing about it, and the migration says exactly that. It is the rule two fields already follow, at
d8df3aa:Run.digline_version(core/run.py:1369), where """means not recorded — a migrated file, a document built in a test — and is never read as version zero" (:1365-1366); andRun.usage(:1416), where "Noneis not recorded: a migrated document, aRunbuilt by hand in a test, a library caller who built one directly. It is never read as zero, on the ruledigline_version = ""already follows — a run measured last month consumed something, and nothing in its document can say what" (:1405-1408). ADR 0014 §1 names the case among its examples: "{}for a record nobody kept".
Zero is excluded. Zero would state that the control ran for every module, in exactly the runs this record says cannot be known: the false green of ADR 0029 §8, written into a migration. An old run says I do not know, not none.
_NON_ADDITIVE was weighed and set aside. A version listed there has no
step, so every document older than it, every run and every baseline, stops
being readable (store/migrate.py:375-384, :410-419), and what it costs
whoever holds the store is a choice between staying behind and doing it all
again.
The refusal inside a step is not needed either. store/migrate.py:330
refuses one document inside an additive step, because that document lacks
something its reading requires. A document written before this field lacks
nothing: it did not record it.
- It does not widen what travels. The count and the names both cross, and
redact()drops neither. The second fact is limited to names that would resolve inside the suite's scope, which is the perimeter from which artifact paths and pinned paths already cross: an artifact's path stays when its text is withheld (wire/run.py:413-415atd8df3aa: "The path stays either way, andwithheldsays which absence it is", by ADR 0003), and a pinned path crosses afterredact()(ADR 0029 §3, held bytest_a_run_document_carries_the_pin_across_the_boundary). This is not a new kind of name at the boundary. It is decided here and not later because ADR 0014 §1 says why: "There is no third answer, and 'we will decide at the boundary later' is the answer that ships the leak."
Its price¶
A suite that counts on module state persisting between runs in one process
changes behaviour. A module-level cache, a counter, a client opened at
import time: after the next load they start again, where they persisted
before. Inside digline-mcp and pytest-digline that is the point of the
change; a suite that relied on it was relying on the defect.
Runs already in a store, measured by a long-lived process after an edit,
are suspect and cannot be told apart. The -dirty marker does not separate
them: in (a) it was present and named a tree that was not executed, and in (b)
the provenance was correct, the suite was two's, while the answer was
another application's. Nothing recorded in those runs says which application
answered. A baseline promoted from such a run carries the same doubt. This
record cannot repair them, and says so.
A judged run can carry the value not recorded, and the headline says so.
Every line number in this paragraph and the four after it is read at
d8df3aa. The reader does not migrate on its own: a document of an older schema is
refused (core/run.py:3047-3051 at d8df3aa, if version !=
SCHEMA_VERSION). After a deliberate digline migrate, a run written before
the code that implements this record carries modules_unchecked as not
recorded, and it can be the judged run by every road: digline compare --run
with its key or with latest (cli/main.py:135, host/resolve.py:98); the
compare tool of digline-mcp (server.py:266, resolving at :223); and
pytest-digline, which always compares latest (plugin.py:479).
The rule: the headline says it, and it moves no number. The precedent for the
form is fact.target_config.unknown (report/render.py:1390-1391): "The
configuration of the system under test is not recorded on both sides, so
whether it changed is not known." (report/text.py:334-337).
The same precedent is silent in one case, and that silence does not apply
here. It rests on "a sentence that reassures about a configuration nobody
recorded is one the reader learns to skip" (render.py:954-956). This
sentence does not reassure. It warns, about runs this record declares suspect
and indistinguishable.
usage and digline_version are not counter-precedents. Neither puts the
ground of the verdict in doubt, and the freshness of the modules a run
measured does. An empty digline_version is read outside the headline, and
never into the exit code (_warn_if_ahead, cli/main.py:223-237).
What the sentence states is exact: that the value is not recorded, and so
that whether the application was read from source is not known. It does not
state that the run comes from before the correction. Not recorded also
covers a document built by hand in a test or by a library caller, as the
comment on Run.usage says (core/run.py:1405-1408). Its wording is measured
when the failing tests are written; what this record fixes is the fact and the
work the sentence has to do.
Not covered here.
--target <file>within onediglinecommand, which loads a second file through the same_import; and a dotted spec.- Promotion. A baseline promoted from a run that declares
modules_uncheckedcarries the same doubt as the run. This is coupled to the half of #483 aboutpromote, which reads no comparison. A refusal at promotion would arrive red on the pytest layout this record calls normal, so this record does not add one.