- Status: accepted — the text first, then the implementation written against it, the way ADR 0014, ADR 0027, ADR 0028 and ADR 0029 were
- Shipped: unreleased
- Date: 2026-09-24
- Opens: nothing. No schema moves, no document grows a field, no wire key
is added or removed.
SCHEMA_VERSION,OUTPUT_VERSION,REGISTER_VERSIONandJOURNAL_VERSIONall stay where they are. What moves is one default in the CLI and one sentence in four shipped descriptions - Assumes: ADR 0002 §1 (the
baseline is a reviewed artifact and the tenant is the perimeter);
ADR 0011 §the shape of the API is the argument
(there is no policy here, there is no promote) and its Consequences — the
sentence this record falsifies and amends;
ADR 0022 §5 (a promotion checks the declared
price digest, which is why
viewcarriespricingat all); ADR 0031, merged as #108 while this record was being written — §6 below is the one place the two touch - Touches:
CLAUDE.md's fixed section only through decision 2 — the baseline lives in.digline/<tenant>/and is versioned in git, so what may write there is a perimeter question and not a convenience question. No fixed decision is amended - Turns into surface:
docs/view.md— the section The one route that writes, whose title stops being true by default;README.mdandplugins/digline/.claude-plugin/plugin.jsonand.claude-plugin/marketplace.json, whose promise acquires the clause it always needed;AGENTS.md§1 and both copies ofoperating-digline'sSKILL.md§1, where the rule stops naming a command and starts naming the act; and — for §4a —SKILL.md§8 plusdigline-mcp'sdescriptions.py, which stop disagreeing aboutmigrateand both carry the condition the permission rests on - Credit: kantorcodes1, who found it while writing a digline profile for HOL Guard — by classifying what each command does rather than by using it. That is the reading nobody here performs, and §7 is about why
Context¶
digline ships a guarantee, in the places a machine reads and the places a
person does — plugin.json, marketplace.json, the MCP server's own
instructions and tool descriptions, its pyproject.toml, README.md,
docs/mcp.md, examples/operator/README.md, the pytest-digline plugin, and
ADR 0011. The shortest form of it is in
plugins/digline/.claude-plugin/plugin.json:
an MCP server that measures, reads and explains, and cannot promote a baseline, because approval is a person's commit
The reason is not a preference about tidiness. It is
ADR 0002 §1 made operational:
a baseline is an approved reference, it is committed under
.digline/<tenant>/baselines/, and the approval is the whole of its meaning.
operating-digline §1 says what follows for an agent — an agent that promotes
on its own dissolves the word; the file still says baseline, and nobody
decided anything.
ADR 0011 built the surface to match, and argued the shape rather than asserting it:
A
promotetool that raised "not permitted for agents" would be a better error message and a worse design: it teaches that promotion is something this surface does, subject to a policy, and a policy is exactly the kind of thing a future release relaxes "just for CI". There is no policy here. There is no promote.
The absence is even proved rather than trusted: examples/operator/loop.py
calls promote by name on the MCP surface every cycle and expects unknown
tool, beside one write that must succeed, because refusal alone proves
nothing.
And digline view serves POST /promote.
It is not a loophole in the sense of something overlooked in the small. It is
the documented design of that command — view --help says "browse stored
runs, compare any two, promote", docs/view.md has a section called The one
route that writes, and view.py's own module docstring lists it as the second
of three deliberate properties. Every part of it was decided. What was never
decided is what it means next to the sentence above.
What was measured¶
Read from the code, this looks like it needs a browser and a click. It does
not. _allowed_origin (src/digline/cli/view.py) returns True when there
is no Origin header at all, and says why: "no Origin at all is allowed —
that is a curl or an old browser, neither of which is the attack". Against a
cross-site POST from a page the developer has open, that reasoning is right.
Against a caller with a shell, it is the whole door.
Measured, not read off the source. Two runs in a store, the first one
promoted; then digline view started on an ephemeral port, and one POST with
no Origin header and no credential of any kind:
RESULT POST /promote (no Origin) -> 200; promoted_at moved: True;
baseline now equals run(s): ['2026-09-24T13-43-18-776131-…'];
POSTed=2026-09-24T13-43-18-776131-…; first=2026-09-24T13-43-18-538378-…
The baseline moved from the first run to the second. No digline promote ran.
No MCP tool was called — none exists to call. The plugin hook
(plugins/digline/scripts/ask_a_person.py) never fired, because it reads the
first word after digline and that word was view.
First measured on 6528af7, and re-measured on 69af0b7 — this record's
branch after main was merged into it, carrying #108 (ADR 0031) and #114 (the
refusal classification), both of which changed view.py. _allowed_origin
grew a signature and a real Host check in between; its first line is still
if not origin: return True, and the result above is the second run, not the
first. A green describes one tree, so this one was re-run against the tree that
carries it.
Why this is a contradiction and not a gap in the documentation¶
The tempting repair is to write it down: say in docs/view.md that the view
promotes, cross-reference it from the plugin description, and the reader is
informed. That repair does not work here, and the reason is worth stating
because it is the general case.
Documenting a perimeter closes the gap where somebody does not know. This
gap is not that. It is a declared guarantee with an undeclared exception —
and the declaration is consumed by readers who cannot act on a correction
elsewhere. plugin.json's description is loaded into an agent's context at
install time as a statement of what is possible. An agent that has read
cannot promote a baseline and then finds a POST /promote has not caught the
project in an inconsistency it should resolve toward the prose; it has been
told something false about the world it is acting in. The honest forms are two:
either the sentence acquires "except from digline view" everywhere it
appears, or the exception goes. §1 rules which.
The shape underneath, which is the part that generalises¶
Three surfaces defend this decision, and all three key on a name:
| surface | what it matches | file |
|---|---|---|
| MCP | tool named promote — absent |
packages/digline-mcp/src/digline_mcp/server.py:385 |
| plugin hook | first word after digline is promote or register |
plugins/digline/scripts/ask_a_person.py |
| the skill | "Never run digline promote on your own initiative" |
operating-digline/SKILL.md:23 |
digline view is the act without the name. Each of the three is correct
about what it matches, and the act they exist to govern reaches the store
through a fourth word. Nobody was careless — a guarantee written three times
against a command name is a guarantee about vocabulary, and the store does not
read vocabulary.
Two further facts make this worse than an omission, and both were found by the
sweep in §4 rather than by looking at view:
- The skill does not merely fail to mention
view; it points the agent at it.operating-digline§1 says "Never rundigline promoteon your own initiative." Two sections later, §2 says "digline viewis the table you pick from … Take the run whose per-case profile is closest to typical." That advice is good advice, and the surface it recommends is the one route that promotes without any of the three defences noticing. The skill's ownUSE WHENclause namesdigline viewas a trigger, so it knows the command exists; its rule is still written aboutdigline promote. - The hook's silence on
viewis a tested invariant, not an oversight.tests/test_claude_plugin.pylists"digline view --suite s.py"in the set asserted silent bytest_the_hook_stays_silent_on_everything_else, besidegrep -rn promote src/andecho digline promote. The reasoning behind that test is sound — asking about everything trains people to approve without reading — and it was written whenviewbelonged in that company. §1 is what makes it belong there again; §3 is what keeps the test honest about the one spelling that no longer does.
Decision¶
1. digline view does not promote. A flag enables it¶
digline view refuses promotion by default. digline view
--allow-promote is the server that promotes. Both are supported; the default
is the refusing one.
The reason is not consistency with the MCP, and it matters that it is not:
consistency is an argument about surfaces matching each other, which a future
release can trade away for a good-enough reason. The reason is the same
reason the MCP has no promote, applied to the same facts. ADR 0002 §1 and
operating-digline §1 do not say the MCP must not promote; they say the
approval is a person's, and that it is a person's is the meaning of the word
baseline. A rule about who decides is not satisfied by a surface that
lets a non-person decide — whatever that surface is called, and however
thoroughly its own behaviour is documented.
Why the default and not an opt-in --read-only, which is the shape this
would take if it were about tidiness. An opt-in guarantee is a guarantee
nobody has. The sentence in plugin.json is unconditional and is read
before any flag is typed; a --read-only that must be remembered makes it true
for the careful and false for everyone else, which is the state we are already
in with extra steps.
The decisive test is who pays for each mistake, because the two are not the same size:
- Forgetting
--allow-promote: a person opens the page, finds no button, reads the line naming the flag, restarts the server. Cost: one restart. - Forgetting
--read-only: an unreviewed baseline is written and committed, and the record of what was approved now contains something nobody approved. Cost: exactly the thing the perimeter exists to prevent, and it is silent — a promotion that happened looks like a promotion that was meant.
A default belongs on the cheap side of an asymmetry that large.
And the asymmetry that makes the flag safe rather than a fig leaf. The
person who wants to promote from a browser is standing at the terminal that
typed digline view; they can type eight more characters, and the refusal
tells them which eight. The agent will not — not because it is forbidden, but
because it has read a description saying this surface does not promote, and an
agent does not go looking for a flag to enable a capability it has been told is
absent. That asymmetry is the whole design: the flag is reachable by
intention and unreachable by inertia.
2. The refusal is an absence, in both of the two places a caller looks¶
Two different callers read this server and they do not read the same thing, so "absent" has to be true twice.
On the page, for a person: the button is not rendered. Not rendered and
disabled, not rendered and failing on click. ADR 0011's argument against a
promote tool that raises not permitted is the same argument here and
transfers without amendment: a control that is present and refuses teaches its
reader that promotion is something this surface does, subject to a policy —
and it teaches it every time the page is read, to every reader, including the
one deciding what to try next.
This is already the house pattern. _actions (src/digline/report/pages.py)
omits the button in three cases today — the baseline's own row, a stale
configuration, a run with errored verdicts — and puts a chip naming the refusal
that would have come. Read-only joins that chain.
But it joins it differently, and the difference is the ruling. Those three
are facts about a run: this run cannot be promoted. Read-only is a fact about
the server: nothing here can be. So its marker does not go in the row. A chip
repeated down twenty rows would be both noise and a lie about its own subject —
it would read as twenty per-run refusals rather than one property of what the
person started. The marker belongs once, in the header, next to the suite name,
and it names the flag: this is the only line in the product that has to teach
--allow-promote to somebody who did not know it existed.
On the wire, for a program: /promote is not a route. This is the half
that carries the guarantee, and the half that absenting the button does not
provide: a caller with a shell never renders the page. In the default server a
POST to /promote gets the 404 that any other unknown path gets — no such
action — because that is the true statement. Not a 403, and not a 405: both
of those say you may not, which implies a someone who may, which is the
policy ADR 0011 refused to create. There is no policy here. On this server
there is no promote.
A note on the mechanism, because the obvious one is a trap. _ROUTES
(src/digline/cli/view.py:47) looks like the place to make this change and is
dead code: grepped across the tree, it has zero readers, and it is also
wrong — do_GET dispatches on a chain of literal comparisons and serves
/case/ and /suspend/, neither of which _ROUTES lists, under a comment
calling it "everything this server answers". Implementing §2 by editing it
would produce a change that reads correctly in review and does nothing. Either
it becomes the real dispatch table or it goes; it must not stay as a tuple that
describes the server incorrectly while the server ignores it.
And the two must be computed from one fact, not implemented twice. A page
that decides independently of the dispatcher is a page that will eventually
show a button the route rejects, or hide one it would have accepted. The flag
reaches pages as an argument, the same way locale and has_baseline do;
one value, read in both places.
3. The hook does not watch digline view. It watches view --allow-promote¶
The plugin hook keeps its hands off bare digline view, and the reason is
stronger than the cost of noise. Once §1 holds, digline view is a reading
tool — there is nothing to ask a person about, and a prompt would be asking
about the wrong act. It would also be expensive in the only currency a hook
has: a hook that interrupts reading is a hook people learn to dismiss without
reading, and it spends that credibility on a prompt that has no decision behind
it, leaving less of it for the promote and register prompts that do.
The sharper half is the flag, and it inverts the intuition. digline view
--allow-promote is not a smaller thing than digline promote — it is a
larger one. digline promote is one decision about one named run; the flag is
that decision delegated in advance, for every run in the store, for as long as
the server is up. It is exactly the decision REASONS["promote"] describes,
taken once and made ambient. So the hook watches it.
This costs something honest, and the cost should be recorded rather than
discovered later. subcommand() deliberately steps over leading flags to
find the subcommand and then stops reading; watching --allow-promote means
the hook reads a word that is not the subcommand, which is a change to its
matching model. It is not a change to its principle: the hook's docstring
says "what is matched is the command, never a string", and --allow-promote
is a word of the parsed simple command, produced by the same shlex split
that produces promote. grep -r "digline view --allow-promote" notes/ is
still a grep. The principle holds; only the reach changes, and the change is
from the first word to the first word and, for one subcommand, its flags.
The hook remains what it says it is: a preference, not a wall. It is not the guarantee, and §1 does not lean on it. The guarantee is the default; the hook is what makes the deliberate case deliberate.
4. The sweep: view/promote is not the only one¶
All twelve subcommands were classified — run, rejudge, compare, diff,
list, migrate, promote, register, view, explain, log, report
(src/digline/cli/main.py:974-1159). The question asked of each was the one
that found this defect: what does it write, and does any surface that claims
to withhold that write reach it anyway?
The boundary that matters is which files are committed. The generated
.gitignore covers */runs/ only (src/digline/store/file_store.py:88-92),
so baselines/ and register/ are the committed surface — the diff a
reviewer signs — and a write there is the act this project governs.
register is the template, and it is worth naming because it shows the four
parts fitting together. No MCP tool; the absence is stated to the model in
the playbook — "There is no tool that records one"
(descriptions.py); view's /suspend/ route produces the line and
applies nothing (docs/view.md); and the hook watches the command.
promote has every one of those parts except that one of its own surfaces
reaches around them.
The second instance is migrate, and it is worse in one specific way.
cmd_migrate appends store.baseline_path(...) to the list it rewrites
(src/digline/cli/main.py:745-748), so digline migrate rewrites the
committed baseline file. The hook does not watch it. And the two shipped texts
do not merely fail to cover it — they contradict each other in plain
words:
| surface | what it tells the agent |
|---|---|
| MCP playbook | "propose digline migrate — do not run it" (descriptions.py) |
| shipped skill §8 | "Run digline migrate after the bump." (operating-digline/SKILL.md) |
An agent with both loaded has been given an instruction and its negation about a command that rewrites a committed file.
The skill is right and the MCP playbook is wrong. But the answer is not the part worth writing down, because the answer can flip. The reason is:
promotechanges what the reference says.migratechanges how it is spelled.
That difference is not a matter of degree. A promotion selects — it names one run out of several as the approved one, and nothing but a person's judgement determines which. A migration has no selection in it: every step is required to write nothing semantic, so there is exactly one output for any input, and the transformation is empty of content by construction. A mechanical transformation whose emptiness is testable is not a decision, and it does not become one by touching a committed file. The wall is the same either way — the reviewed diff — and it is the wall that catches a migration that misbehaved, which is precisely what a reviewer can check on a migration and cannot check on a promotion.
The emptiness is not a hope. _add_schema_sixteen is literally return raw,
with a docstring explaining that inventing there "would be worse than usual,
because the field is a control"; _STEPS' own comment says a version absent
from the table "is one whose bump was not additive, and the absence is the
whole statement"; and
ADR 0014 §2 requires additive migration
without invention. This week's bump showed exactly one changed line per
baseline, which is the observable form of all of it.
4a. So the rule is the condition, not the permission¶
An agent may run
digline migratefor as long as every step is required to write nothing semantic. The day a step has to change content,migratebecomes a decision, and this answer flips.
Written that way, the rule carries its own expiry. The permission is a
consequence of a property, so a future release that breaks the property
withdraws the permission without anybody having to remember that it once
depended on one. operating-digline §8 and the MCP playbook both say the
condition, not just the verdict — the MCP's line stops being "do not run it"
and becomes the same sentence the skill carries.
And the condition needs teeth it does not fully have.
test_the_step_moves_no_hash_and_no_timestamp upgrades a schema-9 fixture
through the whole chain, so it covers every step — including steps not yet
written — and it checks that config_hash and created_at survive untouched.
That is real, and it is two fields. There is no standing test that a new step
writes nothing semantic in general; today the ritual and a human reading a
one-line diff are what check it. A condition whose failure is caught by a
person noticing is a condition that expires silently, which is the failure mode
§4b exists to prevent. The test that the semantic content of a document is
unchanged across upgrade_document is owed by whichever release first ships a
step that is not return raw or an added absence.
A third finding, on a different axis, and deliberately not ruled here.
digline view renders render_html directly (src/digline/report/pages.py),
which emits verdict.reason and artifact text for any run not marked
redacted. digline report has --redacted for precisely that document
(main.py:1157); view has no counterpart — its parser takes --host,
--port, --target and nothing else (main.py:1101-1108). On loopback this
is not a defect but the design: the view serves world 1, and
ADR 0002 says the developer
sees everything. The finding is narrower and sharper than "the view leaks":
--host is the control that moves the view out of world 1, and it is
unconstrained and uncontrolled. serve() binds whatever it is given, and the
docstring says loopback "is not a default anyone should change lightly" —
which is a request, not a mechanism. One flag moves a world-1 surface into a
world where the payload/verdict boundary applies, and nothing notices. That is
decision 9 territory and needs its own record; it is filed and not decided
here, because folding a disclosure ruling into a promotion ruling is how one of
them ends up unargued.
Everything else is clean on this question. compare, diff, list,
explain and log write nothing, and the wire layer's omission of reason
holds across all of them. run and rejudge write only under gitignored
runs/, so the hook's silence about them is consistent with its stated scope
rather than a hole in it.
4b. The standing test¶
A sweep is a fact about today. The rule that survives it:
Every write to a committed path under
.digline/is reachable from a known, enumerated set of surfaces, and a new one fails the test.
The test is not "does view promote". It is that the set of code paths reaching
promote_baseline and append_register is written down, and that a path not on
the list is a failure. That is what catches the next view — the one nobody
has thought of — and it is the same construction friction 59 chose for the
refusal types, for the same reason: a list without a test that fails is a
fourth place to forget.
That construction now exists and should be copied rather than reinvented.
src/digline/host/refusals.py sorts every exception digline defines into
refusals and everything else, and tests/test_refusals.py "walks every module
under digline and fails on any exception class that is in neither table" —
which, in its own words, is what turns a type somebody added into a type
somebody classified. The test here is the same sentence with the nouns
changed: walk every call site reaching promote_baseline and append_register,
and fail on one that is not classified. Both records answer the same question
— what happens to the thing nobody remembered to add — and neither answers it
with a list.
4c. Three hook evasions, found by the sweep and confirmed by running it¶
Not part of the perimeter — the hook is a preference, not a wall, and §1 does
not lean on it — but they are defects in what it claims to match, and two are
outside the evasions its own docstring accepts (bash -c, eval, ssh,
aliases, variables). Measured by piping a payload into the script, not read:
| spelling | hook fires? |
|---|---|
digline promote --run x |
yes |
uvx digline promote --run x |
yes |
python -m digline.cli promote --run x |
yes |
uv tool run digline promote --run x |
no |
python -m digline.cli.main promote --run x |
no |
uv tool run is the long form of uvx, which the matcher handles; it checks
words[1:2] == ["run"] and uv tool run fails that. And
python -m digline.cli.main is a working entry point — verified, digline
0.19.0, exit 0 — that MODULES = {"digline", "digline.cli"} does not list.
In the same line, "digline" is dead: there is no src/digline/__main__.py
and python -m digline refuses with "cannot be directly executed". The set
lists a spelling that cannot run and omits one that can.
These do not ride this record's code. The hook is a plugin file and this
ADR is core; the two live on different release trains, and folding a
plugins/ fix into a change to src/digline/cli/view.py would couple them for
no reason but that one sweep found both. They ship as their own small piece
with the plugin's next release, the dead "digline" entry going with them.
And that piece should say what it confirms, because it is not a separate
lesson: the hook keys on a name, and a name can be spelled differently.
That is this record's finding one level down. §The shape underneath is about
three surfaces keying on the word promote while the act reaches the store
through the word view; this is the same surface keying on the word digline
while the same command arrives spelled uv tool run or digline.cli.main. The
first was found by classifying, the second by running it — and both were
invisible to reading the code, which is the only thing either of them has in
common with how they were missed.
5. What the shipped sentences say now¶
The clause every description needed is not "except from digline view" —
§1 removes the exception rather than documenting it. But two sentences were
saying more than they had checked, and they are corrected rather than left to
become true by luck:
- ADR 0011's Consequences — "A coding agent can read a digline result correctly and cannot promote a baseline, because there is nothing to call" — is the sentence this record falsifies. Its subject is the agent, not the server, and its reason was a claim about the whole reachable surface that had only been checked against one package. It is amended in place, with a pointer here: the claim stands, and now stands for the stated reason.
operating-digline§1 andAGENTS.md§1 stop naming a command. "Never rundigline promoteon your own initiative" becomes a rule about the act — never move a baseline on your own initiative, by any route — with the command as its example rather than its definition. This is the correction with the longest reach, because it is the one that would have caught the next instance without anybody finding it first.
plugin.json, marketplace.json, README.md and docs/mcp.md are left
alone. They say the MCP server cannot promote; that was true, is true, and
was never the sentence at fault.
6. Where this meets ADR 0031¶
ADR 0031 and friction 59 fixed
do_POST's refusal tuple — the hand-listed exception types that let
SuiteMismatchError and DocumentRefusedError through as a dropped
connection. Both landed while this record was being written, as #108 and
114, which is the ordering this section asked for: 0031 first, because it¶
fixed a defect in a published release, and this record only changes a default.
That work is not made redundant by what §1 decides, and the reason is worth
keeping. §1 removes the route from the default server; it does not remove it
from --allow-promote, which is precisely the server a person promoting from a
browser is using — and a promotion that silently succeeds while appearing to
fail is at its worst for exactly that person. The classification in
host/refusals.py is what keeps the flagged server correct, and §4b is the
same construction pointed at call sites instead of exception types.
7. The reading that found it¶
This was not found by using digline. It was found by classifying it —
writing down, for an external tool, what each command does to the world. That
reading asks one question this project's own reviews do not: is this command a
read or a write? Every surface here was reviewed by someone asking whether it
was correct, and POST /promote is correct; it does what its documentation
says, with an Origin check, through the same call as the CLI, with the same
refusals. Correct and contradictory are independent properties, and only the
second one is visible from a table of what-writes-what.
The generalisation, which is the reusable part: a guarantee stated about one surface is a claim about every surface, and nothing in a per-surface review ever puts two of them side by side. The test in §4 is the standing form of that question, and §7 is why it is a test rather than a note asking people to remember.
Consequences¶
digline viewchanges behaviour for existing users: the button a person clicked yesterday is gone until they pass--allow-promote. This is a deliberate break in a minor release, announced inCHANGELOG.mdunder its own heading rather than in a list of fixes, and the refusing server names the flag in two places — the startup line and the header chip — so the discovery path does not run through the documentation.view --helpstops saying "browse stored runs, compare any two, promote".- The guarantee becomes provable on the surface that is shipped to agents,
the way
examples/operator/loop.pyalready proves the MCP's. That probe proved the narrow thing correctly and the sentence beside it stated the broad thing; §4's test is what closes the distance between them. - What this does not do, so nobody assumes more: it does not authenticate
the view, and it does not make
--allow-promotesafe against a cross-site POST beyond theOrigincheck that is there. A person who passes the flag has the server that existed before this record, with its existing protections. The default stops being that server; the flagged one is not hardened by this change. - It does not touch the store.
promote_baselineis unchanged, its refusals are unchanged, and a.digline/written before this release reads identically after it. - Three corrections ride this change because they are in the files it opens,
and each is a statement that is wrong rather than a preference:
_ROUTES(dead, and describes the server incorrectly — §2);docs/view.md's "the same three refusals (tenant, configuration, errored verdicts)", wherepromote_baselineraises four distinct types in its body —ConfigMismatch,Replayed,Errored,Uncalibrated— andview.pycatches six; and the hook'sMODULES, which lists a spelling that cannot run and omits one that can (§4b). migrateis ruled in §4 and §4a, and what lands is the condition rather than the permission: the MCP playbook's "do not run it" is replaced by the same sentenceoperating-digline§8 carries, and both state what the permission rests on. The owed test is named in §4a and is not written here.--hostis filed and deliberately not decided here (§4). It is decision 9 territory and gets its own record; settling a disclosure boundary as a side effect of a promotion ruling is how one of the two ends up unargued.- The hook evasions do not ride this change (§4c). They are a
plugins/fix on the plugin's release train, carrying the sentence about what they confirm.