Skip to content
digline
digline migrate

digline migrate — stored runs across schema versions

A store outlives the schema that wrote into it. migrate brings the runs and the baseline of a suite up to the version this release reads.

What a scan does, and what a named key does

They behave differently on purpose.

  • A scan — digline list, --run latest, digline view — steps over a document it cannot read and says how many it stepped over. A survey stops at nothing it merely fails to recognise. What it must never do is skip in silence, so the count is reported by schema version.
  • A key asked for by name is refused instead. There the caller named that file, and must be told it cannot be read.
$ digline list --suite suite.py
  KEY                                                CREATED                            ENV           COMMIT          CASES
  2026-08-26T15-44-09-492722-00-00-e7421ec503ccefe8  2026-08-26T15:44:09.492722+00:00   staging       b304442         2

* = current baseline

ignored: 1 run(s) at schema 5
run `digline migrate` to bring them up to date

The first version of the listing raised on the first foreign file, which made --run latest fail the morning after a release for a reason that had nothing to do with the run being asked for.

A file whose name is not its run's key is left out first, before its schema is looked at. A run's key is key_of(created_at, config_hash), the name digline files it under, and the store answers to that key alone (ADR 0040). A file renamed or copied by hand is named in the note with what to do:

left out for their name: 'rossi-mario' holds a run not among the listed runs: rename it to 2026-10-04T09-36-41-494925-00-00-8a745d27d002e7dd.json

A copy says the name is already taken and tells nobody to rename it. Two files that hold one run, neither under its key, are both named and neither is told to take the name, because which is the original is not known. Where the key is not a safe file name, the note says so and proposes none. It is not a migration: migrate rewrites a document in place and never renames one. --run latest steps over such a file, so one Finder copy no longer stops it for the whole suite (#429), and the note never says the file was newer.

A document at the current schema that is not a run passes the scan. The scan reads only schema_version, created_at and config_hash, so it is the read after it that refuses the document: a run with a mandatory field missing, or one filed under another suite or tenant. digline list, digline log and digline view leave that run out and name it beside what the scan skipped: refused: 1 run(s): <key> (<why>). Until #314 they failed whole on it. --run latest still refuses, and on purpose: a document that cannot be read has no created_at anyone can trust, so nothing can say whether it was newer than the run latest would otherwise pick, and comparing or promoting the wrong run in silence is worse than stopping.

Migrating

--dry-run says what would happen and writes nothing:

$ digline migrate --suite suite.py --dry-run
would migrate 2026-08-26T15-44-09-282929-00-00-e7421ec503ccefe8.json from schema 5
1 would migrate, 2 already current, 0 refused
$ digline migrate --suite suite.py
migrated 2026-08-26T15-44-09-282929-00-00-e7421ec503ccefe8.json from schema 5
1 migrated, 2 already current, 0 refused

Each file is rewritten in place only after it has been re-read with the new schema: a document that does not parse back is not a document that was migrated. The write goes through a temporary file and os.replace, so an interruption never leaves half a run behind.

Additive and non-additive

  • Additive bumps — a field added with a defensible default — are carried forward.
  • Non-additive ones are refused, and the refusal names what is missing. There is no tenant to invent for a file written before perimeters existed, and inventing one would put a run inside a perimeter nobody put it in.

A refused file is left exactly as it was. 0 refused in the tally is the ordinary case; anything else is a decision for a person, not for the tool.

Additive does not mean empty. Schema 9 added the raw per-sample scores and the interval they span, and the step derives them from metadata["scores"] — a list every sampled run has carried since sampling existed. Reading what is already in the document is not inventing, and it is the difference between a promoted baseline being a noise floor on the day you upgrade and being one after you re-promote everything. A run at samples=1 gains nothing but the version number, because at one sample there is no interval: [score, score] would be a noise floor of zero width dressed as a measurement.

Derived can still change what a run says. Schema 17's step writes the identity a calibration band binds by, taking it from the verdicts the case already holds. Under 16 a band bound by name, and a band for a check that names its score otherwise bound nothing and read as held. After migrating, that band is read. 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. Nothing about the run changed: the document now says what happened. That is why migrate is a person's decision from 0.21.0 on, and why an agent proposes it and does not run it. A file whose verdicts do not say which check its band belongs to is refused and left as it was.

One thing the step deliberately leaves alone: an aggregate — precision, accuracy — records no interval on migration. Sizing one needs the marks and the declared assertion, and a run file carries neither, so a migrated baseline sizes the noise of its cases and reports the noise of its aggregates as not known. The aggregate floors arrive when you promote a run produced under this release.

See also