Skip to content
digline
ADR 0043 — A message digline did not write

ADR 0043 — A message digline did not write

  • Status: accepted 2026-10-05, before any code. Three questions were ruled in conversation before any of this text was written: the frame decides authorship for a refusal too (§1), decision 9 gains a line (§8), and docs/mcp.md is corrected at once rather than with the release (§7). A fourth was ruled on the first draft: the advisory's range is >= 0.6.0 (Consequences)
  • Shipped: 0.29.0
  • Date: 2026-10-05
  • Amended: 2026-10-05, before the repair, in §4, §6, §7 and the Test plan. §6 said no site was found where digline hands what a user wrote to a builtin whose message quotes it. Two were found while writing the repair, and both were measured to the agent: compile() in loader.py, whose SyntaxError can quote a name written in a case, and bytes.decode() in toml_suite.py, whose UnicodeDecodeError quotes a byte of the suite file. The first is declared by name and its message withheld whatever the frame; the second quotes nothing any more
  • Amends: ADR 0041 §4.1, in rule 1, rule 3 and the paragraph on tests, and §4.3, in the sentence a SystemExit is refused with; ADR 0011 §10, in what the translation carries
  • Assumes: ADR 0002 (the payload stays where it is born); ADR 0011 §6 (one rendering per recipient, in digline.wire); ADR 0041 §4.1 rule 2 (the innermost frame says who raised), §4.2 (the type says on purpose, and only that); ADR 0042 §Context (the user's code can read the user's data and digline ships it are two statements)
  • Touches, in CLAUDE.md's fixed section: decision 9. Its list of what crosses and what does not is written in terms of a verdict. The message of an exception raised by code digline did not write is payload of another kind, and the list does not name it. It gains one line (§8)
  • Number: 0043. Swept on 2026-10-05, before a line was written, across origin/main (d786d6f), every local and remote branch and tag, the docs/adr/ of every sibling worktree and the open pull requests (none). The highest number taken anywhere was 0042. Swept again after the text, at 7ce5305: 0043 is only this file, and neither open pull request (#450, #453) carries a record

Context

#445, F-1 of the delta-pass over 0.28.0, published as GHSA-x6w8-q92m-23h3. A suite that raises while it loads is refused by ADR 0041 §4.1 rule 3 with a sentence that carries four things: the exception's type, its message, the file:line locations and a command that prints the traceback. "The text is the same on every front end." So the MCP server hands the whole sentence to the agent, through errors.translated.

The message was written by the suite's code, or by a library it calls, and it quotes what it rejected. int('1.200,50 EUR'), float(), date.fromisoformat() and Pydantic's ValidationError all quote the value. A suite that raises with the row it rejected quotes the row, vars included.

Measured further for this record, in two ways.

  1. In process, at d786d6f, with server.call_tool and a marker in a case's vars:
what raises tool the marker reaches the agent
M1 the suite, ImportError(f"… {row}"), and ModuleNotFoundError likewise list_runs yes
M2 the suite, RefusedError(f"… {row}") list_runs yes, bare, with no location
M3 a target, sys.exit(f"… {case.vars}"), during the run run yes, through refused_exit
M4 a target's own preflight, RefusedError(f"… {cases[-1].vars}") run yes
M5 a target, ValueError(f"… {case.vars}"), case by case run no. It becomes an errored verdict's reason, which wire never emits
M6 the suite, FileNotFoundError(row); a preflight, ValueError(vars) no, by accident: each reached the agent as Error executing tool
  1. Over stdio, against the packages on PyPI: a real client, and each server installed from the index with the digline-mcp the resolver chose:
digline (digline-mcp) ImportError(row) while loading UsageError(row) raised by the suite RuntimeError(row) while loading
0.6.0 (0.1.0), the first MCP release not measured crosses not measured
0.23.0 (0.4.2) Error executing tool crosses Error executing tool
0.24.0 (0.4.2) crosses crosses Error executing tool
0.27.0 (0.4.3) crosses crosses Error executing tool
0.28.0 (0.4.4) crosses crosses crosses

ImportError crosses from 0.24.0, the first release that carries 27bc37e. A digline refusal type raised by the suite crosses on the server's first release and on every release measured after it, because the translation passes every type in REFUSALS. The releases between 0.6.0 and 0.23.0 were not measured. 0.28.0 widened the first column to every exception. It also added the run-time SystemExit: before 0.28.0, that call ended the server (ADR 0041 §4.3).

Why the type cannot say who wrote the message. ADR 0011 §10 translates the exceptions in REFUSALS and re-raises each "carrying the message digline wrote". That held only as long as being in REFUSALS meant being written by digline. Two things broke it:

  • Digline wraps. Every site that wraps another exception's message in its own refusal turns text it did not write into a type it owns. ADR 0041 §4.1 rule 3 and §4.3 are two such sites, and §4 below counts the rest.
  • The types are public. RefusedError is exported from digline.core, and UsageError from digline.host. A suite that raises one writes a message the translation takes for digline's.

ADR 0041 §4.2 has already said it: "The frame says who raised. Only the type can say on purpose." A type that says on purpose says nothing about authorship.

Decision

1. Who wrote a message is read from the frame that raised it

A message is digline's when the innermost frame of the exception that carries it is in digline's code. It is the test ADR 0041 §4.1 rule 2 and §4.3 already use. This record applies it to one thing more: a refusal.

  • An exception in REFUSALS whose innermost frame is not digline's is not digline's refusal, whatever its type. It reaches a front end as code digline ran that raised, which is how rule 3 treats every other exception. This closes M2 and M4 of §Context, and it closes the general case: a type cannot be chosen to make a message cross.
  • Through every wrap. When digline puts another exception into its own refusal, the author of what it quotes is the author of the exception it quotes, by that exception's own frame. A wrap does not make a message digline's (§2).

One exception, measured and not declared in advance: OSError. ADR 0041 §4.1 rule 1 passes an OSError through whatever its frame, and it keeps doing so. When digline reads a file, the OSError has its innermost frame in the standard library, not in digline. So the frame rule would charge every failed read of digline's own to the suite.

This was measured, not assumed. toml_suite._cases was asked for a cases file that does not exist. The FileNotFoundError's innermost frame was pathlib/__init__.py:771 on Python 3.14.5 and pathlib.py:1013 on 3.12.13, and _raised_inside_digline returned False on both.

The exception lasts until #451, which rules on OSError on the MCP server. Until then the MCP does not translate an OSError at all, so its message does not reach an agent. It arrives as Error executing tool (M6, measured with a suite that raises FileNotFoundError while it loads), which is #451's defect and not this one's.

Digline's code is what _is_digline reads today: the directory of digline.__file__. Whether digline_mcp and pytest_digline belong in it is

452's question. It does not move this rule, because neither package raises a

type in REFUSALS.

Declaring it instead was refused. The alternative was to declare that a suite raising a digline refusal writes on digline's behalf. It is false, because the user wrote the text. And with RefusedError public, it would make crossing a matter of choosing a type.

2. The refusal, in fields

Digline no longer interpolates another exception's text into its own sentence. A site that wraps hands the exception to the refusal as a field, and the refusal keeps it apart from what digline wrote:

field what it is who wrote it
the sentence what digline says: "{path} raised …, while it was being loaded" digline
kind the type's name code: a class name, not data
exit_code a SystemExit's code, when it is an int code
locations the file:line frames that are not digline's the interpreter
reproduce the command that prints the traceback, where there is one digline
message str() of the exception, or the repr of a SystemExit code that is not an int whoever raised it, by §1

str() of the refusal is today's sentence, complete. The carrier is defined in digline.core, beside RefusedError, because wraps happen in the core, in run and in host, and the core is the one layer they can all import. Its name, and whether it is a field on RefusedError or a type beside it, are the code's to settle against this table.

When the wrapped exception is digline's own by §1, its message is digline's and crosses as before. toml_suite.py:505 wraps Case's own refusals this way, and they reach the agent unchanged.

3. One refusal, rendered for each recipient

The command line, pytest-digline and a library caller read str(), the whole sentence. Their reader is the person who ran the command, inside the perimeter, and 0.27.0 already printed this message there. Python's own handler prints every other traceback in the same place. The caveat is a CI log, the same one _delta_json draws for reason. It is not new, and it is not changed here.

The MCP server renders the refusal through digline.wire, by ADR 0011 §6's rule that a recipient gets one rendering and it lives there. Every field crosses except message, which crosses only when §1 says digline wrote it. Where it is withheld, the sentence says so and says how to read it:

…/suite.py raised ValueError at …/suite.py:12, while it was being loaded. Its message was written by that code and may quote a case's data, so it is not shown here. For the full traceback, run: cd … && python -c '…'

The agent can run the command. If it does, the message comes in by the agent's own act. That is the line ADR 0042 drew: the user's code can read the user's data, and digline ships it, are two statements.

errors.translated stays the only translation, and it classifies twice: by type, as before, and then by §1. A refusal raised outside digline's code that reaches it with no wrap, such as M4's preflight, is rendered as a wrapped one is, with its type and location and without its message.

4. The class, enumerated

Every place where a message digline did not write can reach an agent. They were found by a grep for an exception's text interpolated into a string, over src/digline and packages/*/src at d786d6f.

It carries what code digline runs was given, a case's data included:

site since measured
loader.py, rule 3, the file form and the dotted form 0.28.0 #445
loader.py, _described: the repr of a SystemExit code, while loading 0.28.0 #445
loader.py, refused_exit, during run 0.28.0 M3
loader.py:222 and :246, an ImportError's text 0.24.0 §Context 2
a type in REFUSALS raised by the suite, while it loads (rule 1 passes it through) the first MCP release §Context 2
a type in REFUSALS raised by code a tool runs: preflight, a target, a check measured at d786d6f only M4
loader.py:169–171, compile()'s SyntaxError, a builtin (§6) 0.1.0, on the MCP since its first release at 58dfb60 only (§6)
toml_suite.py, _parse: bytes.decode()'s UnicodeDecodeError, a builtin (§6) 0.5.0, on the MCP since its first release at 58dfb60 only (§6)

It carries another library's words about the suite's own declarations:

  • toml_suite.py:459 and :645, a provider plugin refusing its set-up;
  • the OSError, tomllib, json, UnicodeDecodeError and SyntaxError texts at toml_suite.py:270–276, :474–482 and loader.py:171;
  • re.error at assertions.py:762 and pii.py:67;
  • HttpTarget._said, urllib's words at a HEAD probe that sends no case.

All of these move to §2 as well. The rule has no list of messages known to be harmless: such a list would be a declaration, and a declaration is what §1 refused. What digline wants from them, a line and a column, an errno, a file name, it formats itself from the exception's attributes, and that text is digline's.

Not in the class for this recipient. The reason of an errored verdict: "target raised …", "the judge raised …", "the scorer raised …" (driver.py, assertions.py, adapters.py). wire never emits a reason, and the control M5 confirms it. They are written into the committed baseline, where decision 9 already rules on them.

digline view, checked before the code and not in the class. A page it serves cannot receive a refusal raised while a suite loads.

  • The suite loads once, in cmd_view, before serve() binds a socket. A suite that raises there gets no server. Measured: exit 64, and the full sentence on stderr, the marker included. That is the command line's rendering, for the person who typed the command.
  • No page reloads the suite. Measured on the four reading routes (/, /compare, /case/…, /suspend/…) with a judge whose config, instrument and price each raise RefusedError carrying the marker. All four answered 200, and none carried the marker.
  • Digline serves no projected page itself. Every call to suite_runs in src/ and packages/ passes mint=None. So listing._why's projected branch has no caller here that serves a page.

Two things are not covered. The /promote route of --allow-promote was not measured. And --host accepts an address that is not loopback, so whoever starts the server decides who reads its pages. Neither is a load-time path.

Digline's own sentences that quote a value. A grep for vars-shaped interpolation in the source found none. It is a grep by name, so it is not exhaustive. Digline's sentences quote names (a case's id, a group, a tenant), which decision 9 lets cross.

5. An ImportError and a SystemExit: what of them is code

  • ImportError. The common case is the uvx one 27bc37e was written for: a plugin is not installed, and the module's name is the whole diagnosis. When the message is exactly the one the import system writes from the exception's own attributes (No module named 'x', and cannot import name 'y' from 'x' (…)), digline rebuilds it from name, name_from and path, and that text crosses. Any other message is §2's message. A suite that imitates the template on purpose gets its text through. That is a deliberate act, and it is stated here rather than discovered.
  • SystemExit. An int code is the code that was asked for, and it crosses. A code that is not an int, as in sys.exit("…"), is a message, and §2 holds it.

6. The limit, stated where the rule is

A builtin has no Python frame. int("x") called from a digline function has its innermost frame in that function (ADR 0041 §4.2), so §1 attributes its message to digline, and the message quotes the argument. So §1 is only as good as this: digline does not pass a case's data to a builtin whose message quotes it, at a site whose exception reaches a front end. No such site was found. The search was §4's grep, and it is not a proof. The gate in §7 drives the paths it knows. It cannot drive one nobody has written yet.

Amended 2026-10-05, before the repair. The sentence "No such site was found" stopped being true that day. It is kept, because it was honest when it was written: it said that the search was a grep, and that a grep is not a proof. Two sites were found while writing the repair, and both were measured to the agent. This section is about the class, and the class now has two measured instances. The first was found by asking what str(SyntaxError) holds when compile() fails on a suite whose cases are written inline. The second was found while moving to §2 the sites listed by the gate on the wraps (Consequences). Once the loader's own sites were moved, that gate still read 38 hits in src/ and packages/. Two were pytest's own UsageError, which is not digline's. Of the 36 left, §4's grep had listed 11. §4 was a grep, and the gate is a check.

The first: compile().

  • The site. loader.py:169–171 compiles the suite with compile(), a builtin, and wrapped its SyntaxError as "{path} does not parse: {exc}". The innermost frame is loader.py's, so §1 attributes the message to digline, and it crossed. The wrap is in every release since 0.1.0. It has reached an agent since the MCP server's first release, because UsageError is in REFUSALS, but that was not measured on any release: only at 58dfb60.
  • The measurement. A suite with its cases written inline and a marker in a case, and the syntax error on the case's line or on the line beside it. 32 variants, on Python 3.12.13, 3.13.11 and 3.14.5, identical on all three. str(SyntaxError) is always "msg (suite.py, line N)", and the text of the line (exc.text) is never in it. In 31 variants the marker was absent: the messages are fixed, or quote a token of the grammar ('}', 'z', '=='). One crossed: "keyword argument repeated: ", from a case written as vars=dict(IT60X0542811101=1, IT60X0542811101=2). It crosses only when the data has the shape of a Python identifier and is written as a keyword's name, twice. A value with a space, or one that starts with a digit, cannot get there.
  • Proved to the end, at 58dfb60. Over that suite, list_runs on the MCP server answered with a ToolError whose text was "…/suite2.py does not parse: keyword argument repeated: IT60X0542811101 (suite2.py, line 7)". The command line printed the same sentence and exited 64.
  • Beside it, one character. "invalid character '’' (U+2019)", from a value written between typographic quotes, quotes one character of the case's line. It is not the marker. It is the same thing in small, and the repair takes it by construction, with no rule of its own.

The second: bytes.decode().

  • The site. toml_suite.py's _parse decoded the suite file with raw.decode("utf-8"), a builtin, and wrapped its UnicodeDecodeError as "{path} is not UTF-8: {exc}". The innermost frame is toml_suite.py's, so §1 would attribute the message to digline. The message quotes the byte it could not decode. The wrap is in every release since 0.5.0, and has reached an agent since the MCP server's first release. That too was measured only at 58dfb60.
  • The measurement. A TOML suite carrying, in a comment, Mario Rossi with the è written as the single Latin-1 byte 0xe8. Over MCP's list_runs, at 58dfb60 on Python 3.14.5, the agent received "…/suite.toml is not UTF-8: 'utf-8' codec can't decode byte 0xe8 in position 95: invalid continuation byte". The command line printed the same and exited 64.
  • What it quotes. One byte and its offset: the same thing in small as invalid character, above. It cannot carry a name or a value, but it is a byte of a file the user wrote, chosen by the builtin, and the frame cannot tell that apart from digline's own words.
  • The repair. It quotes nothing. Digline's sentence says "{path} is not UTF-8 at byte N", from exc.start, the form loader.py already used for a suite written in Python. With nothing quoted there is no message to withhold, so the site needs no declaration.

The repair of the first, ruled 2026-10-05 before the code. Digline's sentence says only "{path} does not parse at line N, column M", from exc.lineno and exc.offset. str(exc) becomes §2's message, and on the MCP server it never crosses, whatever the frame. It is not left to §1, because §1 is what this site defeats. This site is declared here, by name, as one of §6's. §7's gate drives it, with its control on the command line.

What the finding says about §1. The frame rule cannot see a builtin called by digline. This section already stated that as the limit, and its one defence was that no such site had been found. Now there are two. The next one is not excluded by anything in this record but the grep and the gate, and neither can drive a path nobody has written yet.

7. docs/mcp.md, and what holds it

The page was corrected on 2026-10-05 (#453). The correction stands until the release that carries this record, and then it is replaced by one sentence: What never crosses holds for refusals too, and a refusal that quotes code digline did not write names the command that shows the message.

The gate already looked in refusals, and in only one. packages/digline-mcp/tests/test_boundary.py plants a marker and searches every tool's response for it, "the refusals too". Its docstring names the danger of #445 exactly: "an error message that quoted the thing it was refusing about". But the only refusal it provokes is run on a sound suite, and digline writes that one. No path in the gate gets code digline did not write to raise with the marker in reach.

And a test held the defect in place. test_a_suite_whose_own_code_raises_is_refused_with_its_location, ruled by ADR 0041 §4.1, asserts that the message does reach the agent. So the page went false and the suite stayed green.

So the gate is widened, from one refusal to the class. test_boundary.py plants a marker in vars, drives every tool into every path in §4's first table, the run-time ones included, and asserts that the marker is absent from every ToolError's text. Each path has a control on the command line, where the marker is present. A gate that drives no path where the marker could appear passes vacuously, and the controls are what make it able to fail.

8. Decision 9, in CLAUDE.md

One line is added after the list of what crosses, in the change that carries this record:

Added 2026-10-05 (ADR 0043). The message of an exception raised by code digline did not write is payload too: the suite, the application it imports, a target, a library. Who wrote a message is read from the frame that raised it, through every wrap, never from its type. It reaches the person who ran the command and no other recipient.

Until the code ships, the line in CLAUDE.md carries one sentence more: it says that a refusal on the MCP server still carries such a message, and names the advisory. The sentence goes in the change that ships the repair. A rule that is not yet true says so where it is stated.

Amendments

  • ADR 0041 §4.1, rule 1: "A refusal or an OSError passes through" becomes: a refusal whose innermost frame is digline's, or an OSError, passes through. A refusal raised elsewhere falls under rule 3. On the command line that adds a location and a command to the sentence, and the exit code stays 64. OSError keeps passing through whatever its frame. That is §1's measured exception, and it lasts until #451.
  • ADR 0041 §4.1, rule 3: "The text is the same on every front end" is struck. The refusal is the same on every front end, and its rendering is not (§3).
  • ADR 0041 §4.1, the paragraph on tests: the MCP test asserts that the type, the line and the command reach the agent, and that the message does not. The command-line test asserts that the message is there.
  • ADR 0041 §4.3: a SystemExit's code is §5's: an int crosses, anything else is the refusal's message.
  • ADR 0011 §10: "carrying the message digline wrote" stays as the rule, and is no longer assumed from the type. The translation classifies by type and then by frame (§3), and renders through wire.

Each amended section gains a pointer here, in the change that carries this record.

Not decided here

  • An OSError on the MCP server (#451, and ADR 0041's own Not decided here). Once it is translated, it falls under §1 and §2 like everything else. Whether to translate it is #451's question.
  • What counts as digline's code for a location (#452). §1 does not wait for it.
  • What digline view puts on a page during a request. It writes str(exc) into its pages (view.py:511, :516, :707, :784). §4 shows that a load-time refusal cannot get there. Whether code digline runs during a request, /promote included, can raise a refusal with a foreign message is not ruled here. Who reads that page is whoever the server was bound for.
  • A CI log receives the command line's full sentence. That is the caveat _delta_json already states for reason, and nothing changes it here.

Consequences

  • An agent no longer receives the message of an exception raised by the suite, the application, a target or a library. It receives the type, the locations and the command that prints the traceback. Its diagnosis of a plugin that is not installed is unchanged (§5).
  • A suite that raises one of digline's refusal types gets, on the command line, the same sentence as any other exception from its code: the location and the command, and still exit 64.
  • No SCHEMA_VERSION, no OUTPUT_VERSION. A refusal is not a document.
  • The advisory's range is >= 0.6.0, not >= 0.28.0, ruled on 2026-10-05 from §Context 2. A UsageError the suite raises has crossed since the server's first release (digline-mcp 0.1.0, which requires digline>=0.6.0). An ImportError with a row in it has crossed since 0.24.0. 0.28.0 widened the class: rule 3 extended it to every exception while a suite loads, and it added the SystemExit while loading and refused_exit while a run is under way. The releases from 0.6.0 to 0.23.0 were not measured, and the advisory says so.
  • SECURITY.md, Defects in a released package, declared gains an entry in the shape of digline-bedrock 0.6.0's: what crosses and where, who is exposed, what it does not reach, which release fixes it, and the advisory. It is written after the advisory is corrected, so the two do not disagree.
  • A gate on the wraps. An AST test refuses an exception's text ({exc}, str(exc), repr(exc) of a name bound by except … as) inside the arguments of a constructor of a type in REFUSALS, in src/ and packages/, except through §2's carrier. Without it, the next wrap reopens the class.

Alternatives considered

  • Withhold the whole sentence on the MCP server, back to Error executing tool. It is what 0.23.0 did for most of the class. It brings back the hiding ADR 0011 §10 refused and ADR 0041 §4.1 repaired: the type, the location and the command say where the suite failed, and none of them carries data.
  • Scrub values out of the message, as digline-bedrock scrubs ARNs. A scrubber needs to know what the secret looks like. An ARN has a shape. A case's data has none, and a message that quotes it cannot be told apart from one that does not.
  • Fields only, no frame rule. It would leave M2 and M4 open, because a refusal raised by the suite carries no wrap to split.

Test plan

  1. The boundary gate (§7), over every path in §4's first table, each one with a command-line control where the marker is present.
  2. The rewritten MCP test of ADR 0041 §4.1: the type, the line and the command are present, and the message is absent. Its control is the CLI.
  3. A suite that raises RefusedError and one that raises UsageError, while loading and in a preflight. On the MCP they arrive with their location and without their message. On the CLI they arrive with both, and exit 64.
  4. ImportError: a missing plugin keeps its module name on the MCP (§5), and an ImportError raised with free text loses that text.
  5. SystemExit: sys.exit(3) keeps its code on the MCP, and sys.exit("…") loses its text.
  6. The wrap gate (Consequences), with a control: a wrap planted with {exc} fails it.
  7. Rule 2 still holds: a failure raised inside digline while a suite loads still reaches the agent as an unexpected error.
  8. A suite that does not parse (§6, amended): a case's name written as a repeated keyword is absent from the MCP response, which keeps the line and the column. The command line shows it.
  9. A TOML suite that is not UTF-8 (§6, amended): the byte is absent from both front ends, and the offset is on both.