- Status: accepted — the text first, checkpointed and ruled on 2026-09-24, the code after it. It is a breaking change, so it rides 0.20.0, not 0.19.2: that release was being cut when this record was opened, and this is deliberately not part of it
- Shipped: unreleased
- Date: 2026-09-24
- Amended: 2026-09-24, with the code, on
promote-replacing. These are three facts the text did not foresee, and none of them revisits a decision. The key rule moved into the core asdigline.core.key_of, andFileResultStore.key_fordelegates to it. §2 has the report and the wire print the key, and both import nothing but the core, so the rule could not stay in the store. Thenoneliteral lives beside it asdigline.core.NO_BASELINE, for the same reason: theviewform is rendered inreport. The examples and the image are not yet changed. Each example pinsdigline<0.20, and the image installs the pinned release, so they show 0.19'spromoteuntil their pins move with 0.20.0. Changing them earlier would document a flag their own install refuses - Assumes: ADR 0002 §8 (a
baseline is an approved reference, and promotion has named conditions);
ADR 0011 §1 (the MCP has no
promote) and §6 (one rendering of the truth, two front ends); ADR 0014 §1 (the passenger rule), §2 (a comparison that edits its own reference is not one) and §3 (promoted_at); ADR 0021 §2 (accepteddoes not promote, andpromotedoes not register) and §3 (the entry records the baseline's key) - Opens: nothing. No
SCHEMA_VERSION, no passenger, no migration. The run document gains no field (§1 says why).OUTPUT_VERSIONstays 2:compare --jsongains one key under that version's added-key rule (§2) - Touches: nothing in
CLAUDE.md's fixed section. Decision 2 (the baseline is a committed file inside the repository) is why the defect exists, and why the git wall described in §Context sits outside the tool - Turns into surface:
digline promote(a mandatory flag, a new refusal);digline compare(names the baseline's key);digline view(the promote form carries the key it rendered against); the report header;docs/guide.md,docs/api.md, the README and every example that promotes - Credit: the defect was found by kantorcodes1, a reader on the
r/ClaudeCode thread about the Claude Code plugin. The post was about
something else. They read
promote_baselineanyway and found what is below - Number: 0031. Swept on 2026-09-24 across every ref and every sibling worktree; 0030 was the highest claimed
Context¶
The finding, as the reader put it. promote resolves the run, checks it,
and rewrites the baseline atomically. It never checks that the baseline it
replaces is still the one the run was compared against. Two people review
different runs and promote a few minutes apart, and the second one silently
overwrites a newer reference. It is a classic lost update, and it is real.
FileResultStore.promote_baseline reads the run, applies its seven conditions,
and calls _write_atomic on baselines/<suite>.json. It never reads the file
it is about to replace.
A wall exists, and it is outside the tool. The baseline is a file in git, so the second promotion lands as a diff. Two shapes are worth telling apart, because the wall is not the same height in both:
- Two branches, one parent. Both people promote from the same
main. The second one to merge conflicts onbaselines/<suite>.json, becausemainrequires a branch to be up to date. The conflict is the wall, although a conflict resolved with "take mine" gets past it. - One branch, a moved parent. Person B compared run Y against reference K0
yesterday. Overnight person A promoted X, and
mainnow holds K1. B pulls, runsdigline promote --run Y, and pushes. There is no conflict. The pull request shows a diff from K1 to Y, which is exactly what every promotion pull request looks like. Nothing on the page says that Y was never compared against K1. A reviewer finds that out only by going to look for it.
Why it is still a defect. In the second shape, the review is the only wall. The facts that would reveal the problem exist only on the digline side: the reference the comparison was made against, and the reference present when the write happens. A tool that has both, knows they differ, and stays silent is wrong, whether or not a wall exists elsewhere.
The proposal already stated in public: promote refuses when the current baseline is not the one the run was compared against, and names the key it expected. This record keeps that proposal. Reading the code corrects one word in it.
What the guard buys, and what it does not, in one place so neither is read without the other. It buys this: replacing a reference that moved is never silent. It does not buy this: replacing a reference that moved cannot happen. The refusal prints the key it found, and somebody can copy that key without re-running the comparison. §3 states this limit as plainly as the guarantee, and any surface that describes the guard carries both halves.
What reading the code found¶
- Today digline does not hold the first key. A run is written before any
comparison.
comparereads the run and the baseline in force at that moment, prints, and writes nothing. It is right to write nothing: §2 of ADR 0014 is why a comparison does not edit documents. A run can also be compared any number of times, against any number of references. So at promote time, in a later process and perhaps on a different day, no file says which comparison a person read. The one exception is the register (ADR 0021 §3). It recordsbaseline_keybeside a disposition, but only when someone chose to record one. The public sentence said digline "knows both keys". It knows both keys at different moments, and only the person carries one moment into the other. §1 is built on that correction. - No comparison surface names the baseline's key.
compareprints the sentence and the counts.compare --jsoncarries the headline and the exit code. The report header shows the baseline'screated_atandpromoted_at. The key appears only indigline list, as the*row, and in the register. So a person asked to carry the key would first have to find it somewhere else. digline viewhas the same race, in small. The runs page is rendered against the baseline read at request time. The promotePOSTnever sees that key. A second tab, or a second person on a shared server, can promote between the render and the click. The page already holds the key it would need to send.- The same race exists on the run side, and this record does not rule on
it.
promote --run latestresolveslatestwhen it writes, not when the person compared. If a run is written betweencompare --run latestandpromote --run latest, the person promotes a run they never saw. This needs no second person: a CI job, an agent or a second terminal is enough. Not decided here keeps it open.
Decision¶
1. The key comes from the person, and is derived rather than recorded¶
The expected key is supplied by whoever promotes. It is the key of the
reference they compared against, as FileResultStore.key_for computes it:
{slug(created_at)}-{config_hash}. Nothing new is stored. The key is derived
from two fields every run document already carries, and it is the identity
that list, the register and log already use for a reference.
It is not recorded in the run document, because recording it would record the wrong fact. That is the reason. What follows it only confirms it.
The only baseline a run document could hold is the one in force when the run was measured, since the document is written then and never again. That is not the comparison a person read. The baseline can move between the measurement and the comparison, and then a legitimate re-compare against the new reference would be refused. It can also move between the comparison and the promotion, which is the case this record exists for, and there the recorded key would agree with a comparison nobody made. A field that is wrong in both directions is not a guard. It is a second, confident answer to the question the guard exists to ask.
A compare that stamped the key into the run document instead would be a
comparison that edits documents, which ADR 0014 §2 rejects. A run compared
twice would also carry only the last comparison.
The passenger rule is not the reason, and this record says so, because the
field would pass it. A field such as baseline_at_measure meets all three
conditions of ADR 0014 §1:
- It leaves
config_hashalone. It is a fact about the process, not about the suite. - It migrates to absent, meaning not recorded. A run measured before the field existed recorded no reference.
- It is a key built from a timestamp and a hash that already travel in clear.
It could ride, and cost would be a weak reason to turn it away. Wrongness is a decisive one.
It is not derived from the register. The register holds the right fact, but only when a person chose to write a line. ADR 0021 §2 keeps the two gestures apart, and its Alternatives leaves "a register entry before every promotion" to each team's own policy. A check that ran only after a disposition would be silent in the default path, which is where the lost update happens.
It is the key, not a digest of the file's bytes. digline migrate
rewrites every committed baseline in place when the schema moves. The bytes
change and the reference does not. A digest would refuse every promotion that
was compared before an upgrade and promoted after it. It would also be a string
no person can match against list. One case looks like a hole and is not:
a reference promoted again after it was replaced (K0, then K1, then K0). That
is benign, because the verdicts compared against are the same verdicts.
2. Where the key is carried¶
- Library.
ResultStore.promote_baselinegains a mandatory keyword with no default:expected_baseline: str | None.Nonemeans the suite has no baseline. It is mandatory for the reasonpromoted_atis mandatory: a default would make not checked the ordinary outcome. - CLI.
digline promotegains--replacing KEY | none, and it is required.--replacing noneis how a first promotion is written: it says there is nothing to replace. No run key can benone, andnoneis not a wildcard: it states that no baseline is present, and it is refused if one is. The flag is named for what it asserts. The shorter--overwas proposed and rejected: it reads as an abbreviation of--override, which is the opposite of what it does. Because the flag is mandatory, it will appear in scripts read by people who never read this record, and a safety check whose name reads as a bypass teaches exactly the wrong thing. compare. It names the key it compared against. The terminal prints one line under the headline,against baseline <key>.compare --jsongainsbaseline_key, which reaches the MCP through the samedigline.wirefunction. The report header gains the key besidepromoted_at. The key is a timestamp and aconfig_hash, and both already cross every boundary.view. The promote form carries a hiddenreplacingfield holding the key of the baseline the page was rendered against. ThePOSTpasses that value, never the key of whatever baseline exists when the click arrives.
The check runs last, after the run's own refusals and right before the write. There are two reasons. A run that is errored, replayed, uncalibrated or under an old configuration is refused whatever the reference, and sending that person to re-compare first would send them round twice. And placing the check beside the write makes the remaining window as narrow as this design allows (see Not decided here).
3. No force flag, and what the guard is without one¶
The guarantee: replacing a reference that moved is never silent.
The limit: replacing it is still possible without a new comparison.
The refusal names the key it found (§4), so a person can copy that key into
--replacing without comparing anything. digline cannot tell someone who read
the comparison from someone who copied a string. The two sentences go together,
here and on every surface that describes the guard. A record that sells a
guard as more than it is leaves its readers with a false belief, and nothing
checks for that until someone relies on it.
What makes the guarantee worth having, even with that limit, is that the person has to name the reference being replaced, having just been told it is not the one they compared against. After that, what happens is their decision, and the git wall still stands behind it.
There is no --force, no --replacing any, and no environment variable. This
was the prior, and the public reason for it is the argument: anyone who
wants to promote anyway runs the comparison again. compare is offline and
deterministic, and it calls no model. Doing the right thing costs one command
and a few seconds. A flag costs more than that, because the flag becomes the
habit. It is the thing somebody types at seven in the evening, and after the
first time it is simply part of how the team promotes. Copying the found key is
possible too, but it is a decision about one named reference. A flag is a
standing permission.
4. What the refusal says¶
The new type is BaselineMovedError. It sits beside the other refusals in
digline.store, joins the tuple the CLI maps to exit 64, and joins the tuple
view turns into view.promote.refused. Missing from either tuple, it would
reach the user as a traceback.
The text names both keys, the reference's promoted_at when it was recorded,
and the command that settles the question. There are three shapes:
- A different reference.
run <run> was not promoted: it was compared against baseline <expected>, and the baseline of suite <suite> is now <found> (promoted <promoted_at>). Promoting it would replace a reference nobody compared it against. Compare it with the current one — digline compare --run <run> — and promote with --replacing <found> if it still holds. - One appeared. This is
--replacing nonewhen a baseline exists. The sentence is the same, withit was promoted as the first baseline of suite <suite>in place of the first clause. - One vanished. The key was expected and no baseline exists. The text reads
the baseline <expected> it was compared against is no longer there, and sends the person togit logoverbaselines/, because a reference that disappeared was deleted by a commit.
Where promoted_at is empty, the parenthesis is left out rather than filled
with unknown. That is ADR 0014 §3's rule for a signature nobody dated.
5. What does not move¶
- The MCP has no
promote(ADR 0011 §1) and still has none. It gains a key incompare's answer. - The seven existing conditions, their order, and
view's pre-computed chips are unchanged.viewcannot predict this refusal: it is about time, not about the run, so it has no chip and appears only as a refusedPOST. - The register is unchanged. An entry already records
baseline_key, and a team that requires one before each promotion can now check the two against each other. - The git wall is unchanged, and it is not replaced. It is still the only thing that sees two promotions made on two machines.
Consequences¶
- A breaking CLI change. Every
digline promotewithout--replacingfails with a usage error that says which flag is missing and where to find the key. In this repository that covers the promote steps inpublish.yml(two first promotions, which become--replacing none), the site'shome.jsonreplay, anddocs/guide.md,docs/rejudge.md,docs/register.md, the README, three example READMEs andexamples/quickstart/suite.py. The plugin'sask-a-personhook matches the verb, not the flags, so it needs no change. Theoperating-diglineskill has to learn where the key comes from. - A breaking library change.
promote_baselinehas a new mandatory keyword. Library callers include the test helper inpytest-digline(tests/_cycle.py) and about a dozen test modules here. - It ships in 0.20.0, a minor release, and not in 0.19.2. Its changelog
credits kantorcodes1 and says what they did: they read
promote's code after coming in from a post about something else. view.py's module docstring says "the same three refusals". There are seven conditions and six types today, counting theSuiteMismatchErrorthat 0.19.2 added toread_run. It is corrected in the same change, because this record adds another. The tuple of refusals thatviewcatches does not includeSuiteMismatchErroreither. That is a separate question for whoever owns the 0.19.2 follow-up, noted here because this change edits the same tuple.
Alternatives considered¶
- Record the reference in the run document. Rejected in §1 because it records the wrong fact, even though it would qualify as a passenger.
- Have
comparewrite the key somewhere, such as a sidecar file per run. A comparison that writes is rejected by ADR 0014 §2. Runs are also gitignored, so the sidecar would not reach a second machine, and the second machine is where shape 2 happens. - Take the key from the register. Rejected in §1: it would be silent in the default path.
- An opaque token printed by
compare, hashing the run key and the baseline key, so that only a comparison can produce it. It would stop a key copied fromlist. Rejected because it cannot be read: the refusal could no longer say which key was expected, which is §4's whole requirement. It would also defend against a deliberate person, who can edit the JSON by hand anyway. The tool's job here is to not be silent, not to be a lock. --overas the flag's name. Rejected in §2: it reads as--override.- An optional
--replacing. Absent in the ordinary case means unchecked in the ordinary case, which is where the lost update happens. - A force flag. §3.
- Compare against the current baseline inside
promoteand print the result. That turns a refusal into a promotion based on a comparison nobody read, which is the defect in a new form.
Test plan¶
- Every shape in §4, each with its exact keys in the message: K0 expected with
K1 present;
noneexpected with K1 present; K0 expected with no baseline present. noneexpected with no baseline present promotes. K1 expected with K1 present promotes.- A baseline migrated between the comparison and the promotion (new bytes, same key) promotes. This is the control that fails if the check is ever moved onto a digest.
- The check runs last: a run that is both errored and against a moved baseline is refused as errored.
view: render the page, promote a different run through the store, thenPOSTthe form. The response is refused and names both keys.compare: the terminal line, the JSON key, and the MCP answer carry the same string (the parity suite).promote --helpstates both halves of §3 for--replacing: the guarantee and the limit, in the same sentence.- The CLI exits 64 on the new refusal, and a missing
--replacingis a usage error that names the flag. - One test must fail on today's
main: two promotions of different runs, both with the first reference as--replacing. The second write must not happen.
Not decided here¶
- The window inside the call. The check and the rename are two system
calls. Two processes promoting on one checkout within microseconds can still
both pass. Closing that needs a lock. An
O_EXCLlock file inside a committed directory has its own failure: a stale lock blocks every promotion, and it can be committed. The window this record closes is minutes wide and involves people. The one left open is microseconds wide and involves two processes on the same disk. promote --run latest(finding 4). A run written between the comparison and the promotion is the same defect on the run side. The candidates are to refuselatestinpromote, or to havecomparealso print the resolved run key. Neither is ruled here.- Whether
compare --jsonshould also name the resolved run key. It belongs to the question above rather than to this one.