Plumb
Reading stable · switch to edge
How-to guide

A history beside the board

For anyone who has looked at a red row and wondered how long it had been red.

What this solves

A board is a snapshot of one run, and it is built that way on purpose: the manifest is unlinked before every run so that a stale one can never answer for a run that failed. That is the right trade, and it costs you every question with a when in it.

question a board a series
is this proven now? yes
when did it stop being proven? no yes
how often does it move? no yes
when did anyone last look at it? no yes

The last one is the one people are surprised by. Last changed and last looked at are different facts, and a subject nobody has run in six months is not the same as one that has been steady for six months — but they read identically on a board.

Keep one

A series is opt-in and is never a side effect of asking for a board:

plumb board --series series.jsonl

Nothing is written when nothing changed, which is the ordinary case. A board that quietly grew a store would make asking a question a mutating operation.

What it shows you

plumb series show series.jsonl

subject                     last changed          last looked           changes
ORD-2                       2026-08-26T19:03:41Z  2026-08-26T19:03:41Z        3
ORD-1                       2026-08-26T19:03:30Z  2026-08-26T19:03:41Z        1
ORD-3                       2026-08-26T19:03:41Z  2026-08-26T19:03:41Z        1

Ordered by changes, most-moved first, because that is the column you came for. Each row answers a different question and the three are independent:

Read the three rows above as a reader would:

ORD-1 and ORD-3 both read changes: 1 and mean opposite things. The timestamps are what tell them apart, which is why both columns are printed rather than a single "last seen".

If part of the history has been compacted away, show prints the discarded range before the table and never as a footnote — a reader who scrolls past it would read the rest as the whole history.

A copy says it is a copy. Once a holder has accepted from your series, or once you have pulled one, show says so before anything else:

cache of https://holder.example/series/acme-api, seq 1–4 — derived, not a second opinion

The point is not hierarchy, it is having something to correct toward: two files that both answer when did this break and neither of which admits to being derived cannot be read at all. A cache is not necessarily the shorter one — a holder keeping your history under a bound discards ranges it once held, so your local file can be the longer of the two without being a second opinion. They never disagree about a range they both hold.

A history that cannot show where it began says so, above the count:

history of UNKNOWN EXTENT — this file carries no origin stamp, so it cannot show whether it
  is the whole history. A count below is a floor, never a total.

A series is stamped once at creation, so a file carrying no stamp was not created by a board — it was restored from a partial copy, cut by a hand-edit, or written before stamping existed. None of those is a short history, and reporting one as short is how a lost fortnight becomes a confident changes: 1.

Reading one subject

plumb series show series.jsonl --subject ORD-2

  2026-08-26T19:03:30Z  passed · grounded         tests/test_orders.py::test_refunds
  2026-08-26T19:03:41Z  failed · grounded         tests/test_orders.py::test_refunds
  2026-08-26T19:03:41Z  passed · ungrounded       tests/test_orders.py::test_refunds

Oldest first, one line per transition, each carrying the result and the grounding together. That pairing is the point. This subject broke, came back — and came back ungrounded: the test passes again, and on the way it stopped driving the wired system. The claim is passing and is now being proven of less than it was.

The board does say UNPROVEN for it today. What the board cannot say is that this is a regression: a story that was never wired and one that lost its wiring on Tuesday read identically on a snapshot, because a snapshot only ever shows you the last line. When it changed is the whole question a board is built not to answer.

A transition reading gone means the fact disappeared — no test cites this subject any more:

  2026-08-26T19:03:41Z  passed · grounded         tests/test_orders.py::test_places_twice
  when unknown          gone                      tests/test_orders.py::test_places_twice

A recorded event rather than a silent stop, so a deleted test cannot leave a subject reading forever as whatever it last said. It carries no time of its own, because nothing observed it — what is known is the run that first did not find it.

What the shapes mean

what you see what it usually is what to do
high changes, still moving a flaky test, or a claim nobody has settled look at the transitions — alternating pass/fail is flake, a slide in grounding is scope drift
high changes, quiet for a long time it thrashed and then settled nothing; the number is history, not a warning
changes: 1, looked long ago nobody is running it find out why it left the suite before trusting the row
changes: 1, looked just now brand new — or a replacement for a history that was lost check the origin stamp against the holder's; a fresh file is honestly stamped fresh
result steady, grounding sliding the test still passes and reaches less of the system the more expensive of the two, and the one a board hides

plumb session start reads the same history for you and prints the subjects that have moved more than once, so a story that has flickered for a fortnight is not read as one that just moved.

Keep it somewhere that survives

The awkward case is CI, which is exactly where the questions matter most — it is the one machine nobody warmed up — and exactly where a file does not survive the run. A series is a file a run appends to, so on an ephemeral runner there is nowhere for it to live by default. That is a decision you have to make before you can adopt this at all, so here it is with the costs rather than left to you:

where what it costs
commit it to the repo every CI run writes to your repository, and parallel branches conflict on a file that is append-only by design
a CI cache best-effort by definition — an eviction loses the history, and the next run cannot tell that it did
a build artifact has a retention window, and the next run cannot read it back, so it accumulates nothing
push it to a holder needs credentials on the runner, and a push failure deliberately does not fail your board — so it can stop accumulating quietly

The last one is what this was built for, and its failure mode is the one to set up against: check that the history is still arriving rather than assuming it is. The first three all work, and each trades something you should choose on purpose.

Hand it to a holder:

plumb board --series series.jsonl --push
plumb series push series.jsonl            # the same work, on its own
plumb series pull restored.jsonl          # write the holder's copy to a file

Only what the holder has not already been given goes over the wire, so pushing repeatedly is cheap and safe. Which store a run joins comes from the enrolment where it carries one, and otherwise from subject in plumb.toml.

Two ways to be recognised, and they differ in what leaves your machine. An accountplumb account create, then plumb account claim — signs what it sends, and replaces your test names with a keyed token first: the same test is the same token on every run, so which subject changes most often still answers, and the holder never sees the name. A credentialPLUMB_CREDENTIAL in the environment, with endpoint in plumb.toml — is a bearer token presented beside the body, and on that route your test names travel as written. A test name describes behaviour, so that is the shape of your product leaving on a run, and it is worth choosing rather than discovering.

See it before you send it. --dry-run prints what would go over the wire and sends nothing:

plumb series push series.jsonl --dry-run

Its first line names the route it would take and what that means for your names — signed with names replaced, signed but the passphrase is not set, or under a credential with names as written. Worth reading once before the first push from any machine.

pull takes the file to write, and --subject on it means which store to ask for — not the subject id it means on series show. Two sibling subcommands, one flag, two meanings: check which one you are running before you reach for it.

What it will not do