- Status: accepted — the text first, then the implementation written against it, the way ADR 0014, ADR 0027, ADR 0028 and ADR 0029 were
- Shipped: 0.20.0
- Date: 2026-09-24
- Amended: 2026-09-24, with 0.20.1. §8 records the pattern the record's findings share, now that it has a third instance. No decision in §1–§7 moves
- Amended: 2026-09-25, after 0.20.1 shipped. §8 gains its fourth row, the recogniser that did not recognise itself, and states that not chasing the wrappers one by one is the ruling. No decision moves
- Amended: 2026-09-25 by ADR 0033. One Consequences bullet, the one saying this record does not authenticate the view: the flagged server now promotes only for the browser that opened the address it printed. No decision moves
- Amended: 2026-09-26, with schema 17. §4a's condition met its own expiry,
and the answer flipped as written. The 16 → 17 step writes content, so an
agent may no longer run
migrateon its own initiative.AGENTS.md§8, both copies of the skill and the MCP playbook say so, and a test in each place pins it. The amendment also states what the rule now is: one people follow, like promotion in an agent's shell, not a wall. No other decision moves - 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.
Amended 2026-09-26: the condition met its own expiry, and the answer has
flipped. Schema 17's step (ADR 0024 §4.7, amended the same day) is the first
since 9 to write a value, and unlike 9's it changes what a document says. It
writes the identity a calibration band binds by. A stored run whose band bound
nothing under 16 is read after migrating, so a run that exited 0 can read as
exit 2, and a committed baseline can come to say that its calibration case was
outside its band. That is a change to what the reference says, which is the
line this section drew between promote and migrate. So migrate is now a
decision, and an agent may no longer run it on its own initiative: it
proposes it, and a person runs it or tells it to.
The wording is the argument, and it is worth stating because it is the reason
the flip needed no new ruling. The permission was never about migrate.
It was about the property that every step wrote nothing semantic, and it was
written as a consequence of that property precisely so that it could not
outlive it. The property is gone, so the permission goes with it. What was
ruled on 2026-09-26 is only that the condition had fired, and that all three
surfaces say so before 0.21.0 ships. Nobody had to be persuaded that the answer
should change.
This is a record that carried its own expiry and met it, and it is named here as such. It is the second this week. The shape is worth recognising on sight, because its failure is quiet: a permission written as a verdict would still be in force today, and it would be wrong.
What caught it, said plainly: not a test. The standing check above covers
config_hash and created_at, and the new step moves neither. The expiry was
noticed by a person reading this record's own sentence in 0.20.0's CHANGELOG
while writing 0.21.0's, which is the silent-expiry route this section warned
about, met and survived rather than prevented. The semantic-content test this
section said was owed by the first release to ship such a step is not written.
The property it would have guarded is the one this step breaks on purpose, so
the test would be red by design. Its job passes to the pins that now hold the
flipped answer word for word: tests/test_agents.py for AGENTS.md and the
skill, and packages/digline-mcp/tests/test_playbook.py for the playbook. Each
checks that the instruction is present and that the old grant is absent.
What does not enforce it: the Claude Code plugin's ask-a-person hook
intercepts promote, register and view --allow-promote, and not migrate.
An agent that ignores the playbook meets no wall. Adding migrate to that hook
is the route to one, and it is named here, not ruled here.
So, stated as a status and not only as a gap: the withdrawn permission is now
a rule people follow, not a wall, and it is the same kind of thing the
promotion rule is in an agent's shell. Both are wording that an agent reads and
keeps. Neither refuses an agent. The store's refusals of a promotion are about
the run, never about who asks, and digline promote and digline migrate both
run for whoever invokes them. Rule 1's own rungs name that level: a
protection the identity can bypass is a preference. The two differ in one place
only, and this should not be read as more parity than there is. promote also
meets the plugin's hook, which asks a person before it runs, and migrate does
not. The hook is a prompt on the same rung, not a wall either. On the MCP
surface neither exists as a tool (ADR 0011 §1). That difference is left as it
is, deliberately. Closing it is a decision about the hook, and this amendment
records a condition that fired, not a new control.
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.
Noted 2026-11-03 (ADR 0061
§4), and the paragraph above kept as written. The finding is closed by
removal: digline view takes no --host, and listens on 127.0.0.1. The view
gains two writes there, behind a flag of their own, and a page that writes
must not be one option away from another network.
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. All of it landed while this record was being written, 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.
Corrected 2026-09-24, when the code was written against this text. Until
then this paragraph named #108 and #114 as the pull requests that fixed the
refusal tuple. Neither did. #108 was ADR 0031's --replacing, #114 was the
refusal classification — host/refusals.py and the walk that keeps it
honest — and the tuple in view.py was still six hand-written names after both
had merged. #117 replaced it with REFUSALS (commit 927a16e, "Catch the
classification in every front end"), in the same change that made
_after_promotion say "Baseline set to …" even when the run list cannot be
drawn. The error is the one this whole record is about, one level down: a claim
read off two branch names instead of resolved against the tree. Both facts are
inherited by the code here rather than rebuilt, and neither may regress.
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.
8. A guard that keys on a name is defeated by another spelling of the same act¶
Added 2026-09-24, with 0.20.1; the fourth row and the ruling on wrappers 2026-09-25, after 0.20.1 shipped. Four times in two days a guard in this project named the act it guarded by one of its spellings, and the act arrived by another:
| guard | the name it keys on | the same act, spelled otherwise | how it was found | state |
|---|---|---|---|---|
| MCP, hook, skill | the command promote |
digline view and its POST /promote |
classifying what each command does (§7) | closed by §1, 0.20.0 |
| plugin hook | the word digline and two module names |
uv tool run digline …, python -m digline.cli.main … |
running the hook against spellings (§4c) | closed in 0.20.1: uv tool run is read like uvx, and MODULES is held to the modules that run |
| plugin hook, one level down | the word digline, as the command uv runs |
uv run python -m digline.cli …, uvx --from digline python -m digline.cli … |
closing the row above, by running the hook against spellings | closed in 0.20.1: the hook applies its own recognition to whatever uv runs |
| plugin hook | the flag --allow-promote |
--a, --al … --allow-promot: argparse takes any unambiguous prefix |
the 0.20.0 delta-pass: argparse's documented behaviour as a hypothesis, then run | closed in 0.20.1: view refuses abbreviations |
The third row is the sharpest of the four. The hook already knew every
spelling in it: python -m digline.cli asks by itself. But behind uv it
demanded the literal word digline. A recogniser that did not recognise itself
let the same act through one level deeper. The fix is the guard applying its
own recognition recursively, and that is the second way below, written out:
the thing that accepts what uv wraps is the hook's own rule, so the
spellings are derived from it rather than listed beside it.
None of the four was caught by reading the code that holds the guard. The
last had been read, and reasoned about in writing: the hook's docstring said a
prefix match "would only ever widen this past what the CLI does". It reasoned
about suffixes, --allow-promote=, and was never run against the CLI. The two
that were found, were found by classifying or by running, and that is the only
method the four share.
So this is the rule, and it is about the guard's construction, not about any one guard. A guard that keys on a name is complete only if the thing it guards accepts no other spelling of that name. There are two ways to make that true, and they do not wear the same:
- Make the name the only spelling, on the side that accepts it. 0.20.1 does
this for
--allow-promote.viewrefuses abbreviated options, so the full word is the only spelling, and a whole-word match is complete by construction. - Otherwise, derive the spellings from what accepts them, never from a list
written by hand. A hand list is what §4c found stale in both directions:
MODULESnamed a spelling that cannot run and omitted one that can. 0.20.1 holdsMODULESto the tree: a test finds every module with a__main__undersrc/digline/, runs each, and requires the hook's set to be exactly those. The hook may not import digline, so the test does the asking for it. - And say what is not read as the construction, not as a list. Closing §4c
measured six more wrappers the hook does not read (
env,time,nohup,exec,sudo,command). Not chasing them one by one is the ruling, not an omission. Chasing them would rebuild, one wrapper at a time, the hand-written list this whole section is about, and the list would be incomplete the day after, asMODULESwas. The hook's docstring states what it reads, names those six as examples of what it does not, and a test holds each example silent and named. Its sentence therefore cannot outlive the behaviour it describes.
And the test has the same shape as the rule: it asks the accepting side.
test_a_watched_flag_has_no_spelling_but_its_own builds the real parser and
tries every prefix of every flag in the hook's FLAGGED. It does not read the
hook's table and trust it. A guard added tomorrow is held to it without
anybody remembering to add it.
What the rule cannot reach, said so it is not over-read. A hook that
matches shell commands can never enumerate every spelling: bash -c, eval,
an alias and a variable are all spellings its docstring already concedes. That
is why §1 does not lean on the hook. The default refuses, and the hook is
friction on the shadow path, in Plumbline's terms, not the in-path wall. The
rule above keeps the friction honest about what it claims to match. It does not
turn it into a wall.
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.
Amended 2026-09-25 by
ADR 0033. True of this
record, and it left a door the record did not name: the Origin check
admits a request with no Origin at all, so once a person had started the
flagged server, any process of that user — an agent's shell included —
promoted with one POST, and the hook never saw it, because nobody typed the
flag in the agent's shell. The flagged server now mints a launch key per
start, held in memory and never configurable, and /promote refuses a
request without it: a 403, because on that server somebody may.
- It does not touch the store. promote_baseline is unchanged, its refusals
are unchanged, and a .digline/ written before this release reads
identically after it.
- 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, and it goes rather than becoming the
dispatch table); and the hook's MODULES, which lists a spelling that cannot
run and omits one that can (§4b).
Corrected 2026-09-24. This list also planned a docs/view.md fix — its
"the same three refusals (tenant, configuration, errored verdicts)" against
a promote_baseline that raises four types and a view.py that catches six.
#117 already made it: that sentence now reads "with the same
refusals", and there was nothing left here to correct. Written before #117
merged and not re-read against the tree afterwards — the same mistake as §6's,
in the same record, which is why the code was built from the tree.
- migrate is 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 sentence operating-digline §8 carries, and both state what the
permission rests on. The owed test is named in §4a and is not written here.
- --host is 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.
- One lesson was written down outside this record, in the repository's
CONTRIBUTING.md (not linked: it is not carried to the site, and a link that
resolves on GitHub and nowhere else fails the docs gate), because its reader
is somebody
adding the next opt-out and they will not be reading this. Measured when
this record's code was built: with the default's refusal deleted, 48 tests
exercising --allow-promote all passed — every promotion, every refusal
forwarded, both rebinding guards — and five tests caught it, all five written
about the off state. The temptation to test the flag and call the boundary
covered is not specific to this flag.
- 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.