Plumb
Reading stable · switch to edge
Reference

The command line

For programmers and DevOps who need the exact behaviour of a flag.

The core's commands, the series module that ships in the same distribution, and a handful of options. The options matter less than how they fit together — most confusion comes from not knowing which ones cancel each other out.

plumb --version
plumb board      [--json] [--manifest FILE] [--emit-manifest FILE] [--mutation] [--survivors] [--tests] [--scope NS] [--project DIR]
                 [--series FILE [--push] [--enrolment FILE]]
plumb init       [--project DIR]
plumb footprint  [--project DIR]
plumb session    [--project DIR] {start|wrap-up}
plumb manifest   migrate FILE
plumb modules                                      what is mounted, and what it reaches
plumb account    {open|redeem} [--endpoint URL]
plumb series     {show|pull|push} FILE [--subject ID] [--project DIR] [--enrolment FILE] [--dry-run]
plumb support    …                                 only when a support module is installed

plumb registry is a signpost rather than a command — see below.


The shape of it

                              plumb
                                |
     +--------+--------+--------+--------+--------+--------+
     |        |        |        |        |        |        |
   init     board  footprint session manifest modules  account
  set up  derive and  what it  open and bring a  what is  open one
    a        show     wrote,   close at document mounted  by your
  project   status    where   the board forward          address

              and whatever a module mounts — `series` is the first

    board, in detail:

                        plumb board
                             |
                    was --manifest given?
                             |
              no  +----------+----------+  yes
                  |                     |
        run your test suite      read the manifest
        watch production code    from the file
        build a manifest                |
                  |                     |
      --mutation  |                     |   needs no project and
      --emit-manifest apply             |   no run; reads plumb.toml
      ONLY here   |                     |   only with --project
                  +----------+----------+
                             |
                        the gate
                  derive each story's status
                             |
                       the board
                    text, or --json

The one branch that matters is --manifest. Everything else on board hangs off which side of it you are on.


plumb board

Runs your tests, watches your production code, and prints what each story's status derives to.

The options, and what each replaces

Option What it does
--json the board as JSON instead of the text board, in the format The board, as JSON documents. The text board is the default
--manifest FILE skip the test run and derive from a saved manifest. Repeatable: give it more than once to board several manifests together
--emit-manifest FILE save the manifest this run produced. Any file already at FILE is removed before the run starts, so a run that cannot board (exit 2) leaves no manifest there rather than the previous run's
--mutation also check the tests would notice if the code changed. Only proven stories are mutated; the rest read mutation: null. It edits your source files in place while it runs and puts each back — see what mutation does to your files. In --json, each story's mutation carries total, killed, survived, unreached and a survivors list of {operator, where, file, line, reached}. unreached counts survivors no citing test evaluated — scope is per method, so a story gets mutants on paths its tests never enter, and those could not have been killed by any assertion. A ratio that mixes them answers neither question. It is null — not reported — whenever any survivor's reached is null, which is every Python survivor: the Python adapter does not observe reach, and 0 would claim every survivor was reached. The text board says never reached: not reported
--survivors after the board, list the mutants no test noticed — once each, as file:line operator · where (ORD-1, ORD-4), naming the stories whose tests missed it (the first eight, then how many more). Off by default: a survivor sits in every story that reached its method, so printing them per story repeats one work item many times and buries the verdicts
--tests list every citing test and what it grounded, one line each on stderr — python: grounded tests/test_orders.py::test_wired, and java: … for a JVM run — instead of counting them. Off by default: on a mature suite the citing tests are a small minority, so the recital buries the verdict — the board's output should scale with the spec, not with the suite. Turn it on when a board is not what you expected and you need the per-test fact
--scope NAMESPACE show only stories whose id begins with NAMESPACE
--project DIR read plumb.toml from somewhere other than here. With --manifest, plumb.toml is read only when --project is given — see below
--series FILE also append what this run changed to a series — the history a manifest deliberately is not. Opt-in and never a side effect. An unchanged run appends no transitions, only a marker recording that the run happened. Written with the text board, from a run or from --manifest; with --json nothing is appended, so --json never mutates anything
--push with --series, also send what the holder has not been given. Reports and never fails the board if the holder is unreachable — a board is derived from a run and complete without the network, so an outage must not turn a build red for a reason unrelated to the project. Same work as plumb series push, triggered here instead of separately
--enrolment FILE with --push, the enrolment file to fall back on (default ~/.config/plumb/enrolment.json). Read only when the environment names no destination for series — see Environment
PATH … anything after the options is passed to pytest, e.g. a path to limit collection. Put -- first to pass pytest its own flags: plumb board -- -k orders. Python only

How they intersect

--manifest turns the run off. With it, Plumb reads a file and renders it. No tests run, no plumb.toml is read, and your project does not even have to be the current directory:

cd /tmp && plumb board --manifest ~/saved.json     # works

Add --project DIR to check the ids against that project's namespaces. A saved manifest does not carry your specs, so without a project the board never says spec.orphan — and a log board rendered from the run's manifest would disagree with the run's own JSON. Told the project, Plumb reads its plumb.toml for specs and for the decision and parameter ids it declares, and the board matches the run's:

plumb board --manifest artifacts/manifest.json --project .

Only when told. A plumb.toml that happens to be in the current directory is not read for a --manifest board: the manifest may be another project's, and checking it against your namespaces would name its ids as orphans.

That is what makes a board reproducible: the manifest is the evidence, and rendering it twice gives the same answer twice.

So --manifest cancels the options that belong to a run. --mutation, --tests, --emit-manifest and anything passed to the runner only apply on the run path. Give --manifest and they do nothing. --series and --push work on either path, with the text board.

Sharp edge: plumb board --manifest a.json --emit-manifest b.json writes no file and says nothing about it. --emit-manifest is silently ignored when you are not running. If you wanted a copy, use cp.

--mutation can only turn mutation on. There is no --no-mutation. The flag is OR'd with mutation in plumb.toml:

plumb.toml flag result
mutation = false — off
mutation = false --mutation on
mutation = true — on
mutation = true --mutation on

That asymmetry is deliberate. A flag that could switch mutation off would make the board depend on how it was invoked, and two people running "the same" command would get different answers.

--json and --emit-manifest combine — one run, both files:

plumb board --json --emit-manifest artifacts/manifest.json > artifacts/board.json

--scope is a view, never a gate. A repository can carry more than one spec — a product spec and an operations spec, say — and --scope OPS shows one of them. Every story reads exactly what it reads unscoped: scoping removes rows from your view and cannot move a status, because each story derives from its own citations and nothing else. Narrowing to the spec that happens to look good buys you nothing.

More than one language in one repository

A repository can declare several adapters, each with its own production surface — a Java package and a Python module path are not the same kind of name, so they cannot be shared:

specs = ["APP"]

[[adapters]]
adapter = "python"
production = ["src/app"]
entry_points = ["app.cli:main"]

[[adapters]]
adapter = "java"
production = ["com.example.app.*"]
sources = ["src/main/java"]

Each block's own language table goes under it — [adapters.java], [adapters.python]; a top-level [java] is not read once you use blocks. Configuration has the full shape.

A Java project whose modules compile separately names every root it wants boarded:

[java]
tests = ["scheduler/target/test-classes", "collector/target/test-classes"]

One spec can describe a system whose pieces build separately, and naming a single root would make the board answer for part of the spec while presenting itself as answering for all of it. Each root must also be on the classpath — being discovered from and being loadable are different things.

Note there is no top-level adapter line: once you use [[adapters]], every adapter goes in a block, including the one you started with. Writing both is refused, naming the conflict and what to write — because the two forms mean different things, and a board covering one of two declared languages reads exactly like a board covering both.

Each adapter observes its own language and produces its own manifest; the board is derived from their union. Every fact carries the instrument that made it, so a board mixing a JVM adapter with a CPython tracer is readable rather than quietly incomparable.

A merge refuses rather than reconciles. If two manifests carry the same story and the same test with different facts, that is a defect in the run — not a difference to average or resolve by order — and Plumb says so instead of picking one.

If one adapter fails, the others still report, and the board says which language is missing (adapter.absent). A board covering two languages with one adapter down is not a smaller board; it is a board about a different project, and it must not read as the whole answer.

If an adapter's instrument never reaches your code, the suite still finishes and the board still comes out (observation.failed). The results are the results; only the wiring question went unasked, so grounding reads not-checked for every citation in that language. That is not the same silence as having declared no entry points — if you declared them, they are not the thing to go and look at, and the code is there so the board can tell you which of the two you are in. On Java the usual cause is a production class sitting under a declared test root, where the adapter excludes it along with the tests.

You can do the same thing by hand with saved manifests:

plumb board --manifest python.json --manifest java.json

The codes it can emit

A board carries condition codes beside its prose. The code is the promise — a tool branches on it, and it never changes; the sentences beside it may be reworded whenever they get clearer. The set is finite, and this is it:

Code Raised when
grounding.not-checked grounding could not run — no production entry points are declared
grounding.dispatched the work went to a thread the adapter could not follow, so wiring is unknown
grounding.reached-outside the story grounded, and the same test also ran code no entry point reaches
depth.unsupported a citation claims it reached the wired system and its execution never did
aspects.per-aspect it has more than one aspect, so it has no single status — read the aspects
evidence.none nothing cites it — an absence of evidence, and the correct reading of a fresh spec
evidence.failed a citation ran and did not pass — a result rather than a gap. Raised beside any other condition and on any status, so a failing test is never hidden behind another reason — not even when another citation proved the story
evidence.not-observed something cites it and never ran — no evidence at all, not evidence against
grounding.not-wired the citations that passed reached nothing wired — what ran was the implementation on its own
grounding.inert every citation passed without executing the implementation at all
grounding.untouched the citing test neither ran the production artifact nor read it — assert True lands here
structural.executed a structural subject's citing test ran production code, so it exercised the shape rather than reading it
observation.none no tests were observed at all, so there was nothing to derive from
observation.uncited tests ran and none of them cited a subject — nothing claimed yet, or citations that could not be read
config.ignored a setting in plumb.toml (or a leftover [tool.plumb]) is written where the run cannot use it — a key under the wrong section, an endpoint. Each is named above the board; the run carried on without it
spec.orphan a story id belongs to no declared spec — outside every namespace in specs. Carried on that story, and once for the run
adapter.absent an adapter this project declares did not run, so a language is missing
observation.failed the instrument was declared and never reached your code, so grounding is not-checked everywhere — for the instrument's reason, not yours
unlock.grounding stories passed whose wiring was never checked — declaring entry points would gate them
unlock.mutation stories are proven and nothing has checked their tests assert anything

On the text board each condition is printed once, followed by the stories it is about — the first eight, then a count:

1 story  [grounding.not-wired]  ORD-2
2 stories  [spec.orphan]  PAY-1, PAY-7

A condition about the run itself is headed this run instead. In --json a story's conditions are on the story, and the run's in the top-level conditions.

Every code resolves to its prose in the tool itself. A board carries what, next_step and example beside each code, in the text board and in --json alike, and resolving them needs no network — which matters, because a board is read most often where there is not one.

Five reasons a subject reads unproven, and they want different things from you. The status is the same word; the code beside it is what tells you which situation you are in.

If you see What actually happened What moves it
evidence.none nothing cites it write a citing test — or leave it, on a spec authored ahead of the work
evidence.failed a citation ran and failed fix the code or the claim; this is a result, not a gap
evidence.not-observed a citation never ran find out why it was skipped — no evidence is not evidence against
grounding.not-wired the citations passed, and never reached the wired system drive it through a declared entry point, or accept the depth it reaches
grounding.inert the citations passed without executing the implementation at all assert against the implementation — a test that touches nothing passes forever

The last two are the ones worth knowing about, because the tests are green: somebody has written a passing test and the story still does not move. An unproven with no reason beside it leaves the author who has done the most work told the least, which is why the code is printed with the status.

A subject may say its status in its own words. The status codes above never change — they are what a consumer keys off — but a decision or a parameter is not a story, and proven is the wrong word for a claim about the future. Declare a lexicon in your feed and the board says the word beside the code:

code story decision parameter
proven proven holding supported
unproven + evidence.none unproven silent unvalidated
unproven + evidence.failed unproven breached refuted
unproven + evidence.not-observed unproven unwatched unmeasured

The words are derived, never authored — each comes from a status the gate computed and a condition it carried, so nothing a lexicon says is anything the run did not. A lexicon with no word for a state says the state's own name rather than inventing one.

The one-liners above are for orientation, not for acting on. Each code carries its own what, next and example in the board itself, written for the situation you are actually in — read those rather than these. What this table is for is telling you the set exists and what is in it, so a silence you are looking at can be told from a state you have not reached.

What it exits with

The exit code describes the observation, never the proof.

Code Means
0 a board was derived from a run that observed tests
2 Plumb could not produce a board at all — bad config, an unreadable --manifest, or the test session never finished (a collection error, a usage error, an interrupted run)
3 no tests were observed, so there was nothing to derive from

The line worth reading twice is the first one. Stories being UNPROVEN is exit 0. So is a failing test. Proof happens at your desk, not in CI, and a spec you wrote this morning is all-unproven and green on purpose — a tool that failed your build for that would be enforcing a workflow rather than checking a proof.

Your test runner already has an exit code for a failing suite, and it is a better one than Plumb could give you. Run it.

3 exists because the other two could not tell you the difference between these:

$ plumb board --json        # a healthy project, nothing cited yet
{ "observed_tests": 38, "stories": {}, "conditions": [{"code": "observation.uncited", ...}] }

$ plumb board --json        # a broken classpath, finding no tests at all
{ "observed_tests": 0, "stories": {}, "conditions": [{"code": "observation.none", ...}] }

Both print an empty board, because an empty board is exactly what a correct first run looks like. The first is fine. The second means your runner never found your tests — and if you have wired plumb board into CI, that is the one case where a green step would be lying to you.

2 and 3 are different failures, and the difference matters. 3 is a run that finished and honestly saw nothing. 2 includes a run that never finished — one bad import, a syntax error in a test module, a missing conftest dependency, and your runner stops collecting. Zero tests were observed either way, but only one of them looked:

$ plumb board            # a test module fails to import
the test session did not complete (pytest exit 2), so no board can be derived from it — …
… your runner's own report of the error …
the Python adapter failed under /usr/bin/python3 (exit 2)
$ echo $?
2

Left at 0 this is the most expensive kind of green: a story that proved yesterday reads UNPROVEN today, which for a live spec is the ordinary state — nobody has written that test yet — so a completely broken suite is indistinguishable from routine incompleteness.

A suite that ran and failed is a different thing entirely and still exits 0. That is a complete observation whose answer happens to be no, and it belongs to your test runner.

If you are putting this in CI: the default is already right, and Integrating with your tooling has the whole recipe. plumb board fails the step when it could not observe — nothing collected, or a session that never finished — and passes it when it observed something, whatever the board then says about proof. You do not need to grep the log.

An adapter that cannot report how many tests it saw emits observed_tests: null, and Plumb will not exit 3 on it — silence is not evidence of an empty run. Both shipped adapters report it.

observed_tests counts the tests the runner collected, before your own filters. A run narrowed with -k or -m until nothing is selected still collected its tests, so it exits 0, and every cited story reads evidence.not-observed — its tests were found and not run:

$ plumb board -- -k nomatch      # exits 0; each story: unproven, evidence.not-observed

Exit 3 is for a runner that found nothing at all — a test path or classpath pointing somewhere the tests are not.

Putting them together

plumb board                                   # the normal case
plumb board --mutation                        # slower, deeper — see what-to-expect.md
plumb board --emit-manifest run.json          # run, and keep the evidence
plumb board --manifest run.json               # re-render that evidence, anywhere, instantly
plumb board --manifest run.json --json        # …as JSON, for a tool
plumb board --json --emit-manifest run.json   # JSON now, and the evidence kept
plumb board --project ../other-repo           # run against a different project

A useful pairing: emit once, render many times.

plumb board --emit-manifest run.json          # minutes
plumb board --manifest run.json --json        # instant, repeatable

plumb init

Writes your declaration — plumb.toml — guessing what it can from your layout. Nothing else, in any language.

Option What it does
--project DIR set up a project other than this directory

It ends by printing what is still to do — on a fresh project, naming your entry_points — and how to cite a first story. It writes no specs. Every key it can write, and the ones it leaves to you, are in Configuration.

It never overwrites. Run it twice and the second run reports what it kept. A file it wrote once belongs to your project from the moment it lands.

The declaration is the whole of it. Which adapter, what counts as production code, where the wired system begins. Nothing about where you write your requirements down, what you call them, or whether you write them down at all.

Why there is no list of subjects to point at. Such a list is a copy of a list you own — it goes stale the moment yours moves, and nothing here can detect that it has. So a board answers for the subjects your tests cite, and says so on every run. A subject you have written down and not cited yet is simply not on it. That is a smaller claim than the alternative, and it is one we can keep: what is left to do lives with you, where it is current.


plumb series — the first module

These verbs come from a module, not from the core. plumb.cli names none of them: they reach the command line through the mount every contributed module uses, declared by the plumb.modules entry point in this distribution's own metadata. Remove that entry and plumb series leaves the tool without a line of the core changing.

One consequence worth knowing before it surprises you: a module is discoverable only where its distribution is installed, because modules are found through installed package metadata. pip install plumbspec has it. A source checkout on PYTHONPATH with no install, or a zipapp run against an interpreter nothing was installed into, sees no modules at all — and plumb series is simply absent rather than broken. plumb modules says which are mounted.

The run history a manifest deliberately is not. A manifest is unlinked before every run so a stale one can never answer for a failed one — which is right, and means when did this break, which test is flaky and when was this last checked cannot be answered from it. A series is the second artifact those need.

It records what changed, not what a run found. A fact that has not changed is not new information, so an unchanged run adds no transitions at all — only a marker saying the run happened. A new series also starts with an origin stamp, which is how show can tell a whole history from a partial copy.

$ plumb board --series series.jsonl
series: 323 transition(s) -> series.jsonl

$ plumb board --series series.jsonl
series: 0 transition(s) -> series.jsonl

plumb series show FILE

Reads it back — the three questions above, answered from the history:

$ plumb series show series.jsonl
subject                     last changed          last looked           changes
ORD-14                      2026-08-05T09:00:00Z  2026-08-05T09:00:00Z        5
ORD-2                       2026-08-01T09:00:00Z  2026-08-05T09:00:00Z        1

last changed and last looked are different questions, and the difference is most of why a series exists. ORD-2 has not moved since the first run and was examined on the most recent one — an unchanged subject is not an unexamined one, and a tool that conflated them would report a healthy project as an abandoned one.

changes counts runs, not records. It is what keeps changing — a flakiness count stated as a number rather than a feeling. On a fresh history every subject reads 1, which is the honest reading of one observation.

Option What it does
--subject ID one subject's transitions, oldest first, instead of the summary over all of them
$ plumb series show series.jsonl --subject ORD-14
  2026-08-01T09:00:00Z  passed · grounded         tests/test_orders.py::test_queue
  2026-08-03T09:00:00Z  failed · grounded         tests/test_orders.py::test_queue

That is when did this break — a question no board can answer, because a board is one run.

A discarded range is printed first, never as a footnote. A reader who scrolls past it reads the rest as the whole history:

$ plumb series show series.jsonl
discarded 1–214 at 2026-08-01T00:00:00Z — compacted — bounded history

It reports and never grades. No status, no label, no condition code. Status derives from a run, and a history that graded anything would be a second source of truth about the same subject — the two would disagree the first time somebody opened an old file.

plumb series pull FILE

Fetches the history the holder holds for you and writes it where you say. The other half of custodial: a store that cannot hand it back is holding nothing for anyone.

$ plumb series pull recovered.jsonl
series: pulled 324 record(s) for acme-orders -> recovered.jsonl

What lands is a series like any other, so plumb series show reads it.

Option What it does
--subject ID which series to ask for. Without it: the subject the destination carries, else subject in plumb.toml
--project DIR the project whose plumb.toml names the subject. Without it, the project that owns the directory you are writing to — the nearest plumb.toml at or above it — so plumb series pull ../copy.jsonl from inside a project reads the parent's plumb.toml. Pass --project . to be explicit
--enrolment FILE the enrolment file to fall back to (default ~/.config/plumb/enrolment.json)

Where pull asks is resolved exactly as push resolves where to send: PLUMB_SERIES_*, then the machine-wide pair, then the enrolment file. The endpoint is the holder's base URL; the client asks <endpoint>/series/<subject>.

Exits 1 when the holder has nothing for that subject, cannot be reached, or nothing names where to ask, and 2 when the project's plumb.toml cannot be read — and writes no file — an empty one would read as nothing happened, which is the answer a history exists to keep apart from we no longer hold it.

Retrieval is not a read-only convenience. It is the signal eviction ranks on, so reading your history is part of what keeps it.

plumb series push FILE

Sends what the holder has not been given yet, to the destination named for series — see Environment.

$ plumb series push series.jsonl
series: pushed 324 record(s)
Option What it does
--dry-run print the number of records a push would send and each one as JSON — the origin stamp, every transition with its subject, test, result and grounding, and the run markers — and send nothing. Needs no destination and no subject
--project DIR the project whose plumb.toml names the subject (default: the project the series file lives in — the nearest plumb.toml at or above it)
--enrolment FILE the enrolment file to fall back on when the environment names no destination (default ~/.config/plumb/enrolment.json)

It needs a subject, so set one before the first push — which series, inside the store the credential owns. It is taken from the destination (PLUMB_SERIES_SUBJECT, PLUMB_SUBJECT, or the enrolment's subject), else from subject = "…" in the project's plumb.toml. A holder you run yourself accepts lowercase letters, digits and hyphens. The records go to <endpoint>/series/<subject>, where the endpoint is the holder's base URL. With no subject, it says so rather than guessing one:

$ plumb series push series.jsonl
series: not pushed — no subject to push under: name one with `subject = "…"` in plumb.toml. ...

With no destination named for series, nothing leaves and the push says so:

$ plumb series push series.jsonl
series: not pushed — no destination named for series — set PLUMB_SERIES_ENDPOINT and PLUMB_SERIES_CREDENTIAL, or enrol. ...

The log is its own outbox. Every record carries a dense, monotonic sequence, so what has not been sent is a number rather than a queue. If the holder is unreachable the mark does not move and the next push carries the backlog — nothing is lost and there is nothing to drain.

A large backlog is split, not refused. The first push is the biggest one a project ever makes — it carries the whole base state, where every push after it carries a day's churn — so it is sent in batches sized under what the holder publishes at /spec as series.max_body_bytes. Batches go in order and the mark moves after each one, so a push interrupted halfway leaves exactly the tail that did not arrive.

$ plumb series push series.jsonl
series: not pushed — destination unreachable: <urlopen error [Errno 111] Connection refused> — sending to https://holder.example/series/acme-api, named by PLUMB_SERIES_ENDPOINT and PLUMB_SERIES_CREDENTIAL

The address and where it was named come with it — the capability's variables, the machine-wide PLUMB_ENDPOINT and PLUMB_CREDENTIAL, or the enrolment file's path — so a wrong address reads differently from a holder that is down. pull says the same when it cannot reach the holder.

Exits 0 whenever the series file exists, including when the holder was down or nothing was named, 1 when there is no series at that path, and 2 when the project's plumb.toml cannot be read. A board is derived from a run and is complete without the network, so an outage must never turn a build red for a reason that has nothing to do with the project.


plumb modules

What is mounted, what each module adds, what surfaces it speaks and whether it reaches the network — so trusting a module is a decision over what it declares rather than a hope.

$ plumb modules
  series 1.0.0  verbs: series (push|show|pull)  speaks: series 1  network: yes
      writes the series file named on the command line
      writes its `.sent` mark, beside it

A module that did not mount is listed too, with the reason, rather than being missing:

$ plumb modules
  telemetry  NOT MOUNTED — speaks `telemetry`, which the core publishes no version of — it publishes board, series, transport

The core refuses a module that names a verb the core already owns, one that speaks a surface the core publishes no version of or a version it cannot give, one that declares writes that are not footprint entries, and one that will not import. A module that fails to import is named and every other module still mounts — one broken extension never costs the tool. Nothing here probes whether a module can reach what it needs: a module that cannot is inert, and its own verbs report their own standing, because the core cannot tell a service being down from a credential being wrong.

The surfaces the core publishes, each versioned on its own, are the board, the series interface and the transport. A module declares the version it speaks per surface; the module contract says what each one is.

plumb manifest

Works with a manifest a run produced. One subcommand.

plumb manifest migrate FILE

Rewrites a manifest written against an older schema, in place, at the current one.

A run accepts the current major and one back — stay within one major of the latest is the recommendation. This command reads two majors back, so the rescue path is one major deeper than the advice and nobody is stranded until they are three behind.

Reach for it last. A manifest is derived from a run, and Plumb deletes any previous one before every run, so the first answer to a stale manifest is always re-run your suite. This is for the case where that run cannot be reproduced — an archived CI artifact, a decommissioned system, a handover.

$ plumb manifest migrate archived.json
archived.json: schema 7 -> 9.0

Already current, and nothing is written — not one byte, so a file's timestamp keeps meaning this was brought forward:

$ plumb manifest migrate current.json
current.json is already at schema 9.0

Below the chain there is no path, and it says so rather than naming itself:

$ plumb manifest migrate ancient.json
ancient.json: schema_version 3 is older than this Plumb reads (7.x, 8.x, 9.x). ...
no migration path exists — the chain reaches back to 7.x, and a shape below it
cannot be inferred

Exits 0 when the file is current or was brought forward, 1 when it cannot be read or has no path forward.


plumb support

Not a core command. A support module provides it if one is installed, mounted through the plumb.modules entry point like any other module. With none installed, plumb support is not a command this tool has; plumb modules lists what is mounted.


Environment

A destination belongs to a capability, and comes from the environment of the run. Nothing is sent anywhere unless a destination is named for the capability doing the sending. Three places are read, most specific first, and the first that answers wins:

  1. the capability's own variables — for the run history, which is the capability the core sends:

     PLUMB_SERIES_ENDPOINT     where run histories go
     PLUMB_SERIES_CREDENTIAL   what they go with — a secret
     PLUMB_SERIES_SUBJECT      which series there this credential owns (optional)
  2. the machine-wide set — one address for every capability that names none of its own:

     PLUMB_ENDPOINT
     PLUMB_CREDENTIAL
     PLUMB_SUBJECT             (optional)
  3. an enrolment file — ~/.config/plumb/enrolment.json (under $XDG_CONFIG_HOME if set), or the file PLUMB_ENROLMENT names, or the one --enrolment names.

A module that sends something reads its own variables the same way. The name is the capability's, upper-cased, with anything that is not a letter or a digit becoming an underscore. No PLUMB_SUPPORT_* variable is read by the core.

Both halves or neither. An endpoint with no credential is a half-configured runner, and is skipped rather than used: answering with it would send unauthenticated and report the refusal as the service's.

Naming none is the ordinary state. With no destination for a capability, what that capability produced stays on this machine and the command says so. There is no default address, which is what makes nothing leaves without being told where a guarantee rather than a promise about your configuration.

Prefer the variables to the file where you have the choice. A file in the working tree can be collected by an artifact upload, a cache action or a debug step that archives the directory, and a variable cannot. The file is the right primitive where secrets genuinely arrive as files — Kubernetes mounts, Vault templating.

plumb account reads PLUMB_ENDPOINT when --endpoint is not given. plumb series pull finds its holder exactly as push does, so a history is read back from where it was sent. plumb.toml never names an address: an endpoint key there is not read — the command names it and the variable to set instead, and carries on.


What Plumb writes, and where

So you can decide what to back up and what to carry to a new machine. One thing here is irreplaceable — your series; everything else is either in your repository already, a credential you can be issued again, or rebuilt on demand.

In your project.

Path What it is
plumb.toml your declaration — the whole of what plumb init may write. Commit it
the cursor file the handoff between two sessions (default .plumb/next-session.txt). Commit it
the series store irreplaceable — every transition, losslessly. A run that was not recorded cannot be recovered from a later one. Commit it, or push it to a holder — see Committing the history
<series>.sent how far that history has been handed to a holder, as a sequence number in the series file's own numbering. Keep it with the series — commit it if you commit the series, and let it survive wherever the series survives. Losing it loses no record, but the next push re-sends the whole history and a holder stores the repeat, so changes on the holder's copy reads high until the holder compacts

Outside your project — the half a repository does not carry.

Path What it is Keep it?
~/.config/plumb/enrolment.json an enrolment, file form — written by plumb account redeem, or issued by a holder's operator. This one carries a credential yes; marked SECRET, and shared across every project pushing to that holder
~/.local/state/plumb/ a session's opening reading, keyed by project path no — scratch; a session that loses it is simply told its delta cannot be computed
~/.cache/plumb/ a per-version cache, and under runs/ each project's latest run file and manifest, in a directory named from a hash of the project's path — plumb footprint prints this project's no — rebuilt when missing

Each honours its XDG_* variable.

The one to actually worry about is your series.

$ plumb footprint

prints the same list with your paths filled in, which this page cannot: every location honours its XDG_* variable, so where your enrolment actually sits is a property of your machine. Each entry is marked present or absent — whether it is on this machine now — and the cache entry names this project's own directory under runs/.

Option What it does
--project DIR the project to read plumb.toml from

A mounted module adds a section of its own, headed added by modules, listing what that module says it writes. It is printed last and named as theirs, because the core stops being able to answer for a capability the moment that capability moves out of it — and the files it wrote do not stop existing. A module that writes nothing adds no section. plumb modules names the same places without the dispositions.

This table is a view of plumb.footprint, which lives beside the code that does the writing. A hand-kept list of paths stays right until somebody adds a writer, and nothing goes red when they do.


Backing up — what Plumb tells you, and does not do for you

Plumb takes no archive. plumb footprint names everything it wrote and marks what is IRREPLACEABLE; point whatever you already back up at those paths. That includes what mounted modules declare, so installing one does not quietly put state outside the list.

Taking custody of your files would mean Plumb deciding what counts as a secret, what your repository already carries and what a restore may overwrite — three policies belonging to whoever runs the machine, on a machine that is already being backed up somehow. So Plumb holds the list and leaves the backing up to you.

One thing nothing else holds, so it is the one to point your backup at:

your series every transition, losslessly. A run that is not recorded cannot be recovered from a later one. Committed, your repository carries it; if you git-ignore it instead, a backup rule about tracked files will miss it

plumb session

Optional. Nothing else in the tool reads any of it, and a project that never runs these commands loses no capability. It is worth having for one reason: it opens and closes at the board, so a session is measured by what your own artifact proves rather than by what it felt like. How you spend the time between the two readings is not something it has an opinion about.

plumb session start

What the last session left, whether the ground is sound, and a reading to measure against.

$ plumb session start
  cursor    .plumb/next-session.txt
  next      refunds reach the ledger
  ref       ORD-14
            the handler is in; the ledger client still swallows the error.
  ground    clean
  board     117 unproven of 190
  target    ORD-14 is unproven

  When you are done:  plumb session wrap-up --next '…' --ref … --context '…'

board counts stories only: a subject read per aspect has no single status, so it is in neither number, and the session delta never counts one as proven or lost.

It refuses nothing. A dirty tree, a missing cursor and a reference that resolves to nothing are all reported — the cost of a wrong open lands on you, who can see it. There is no cursor on a first run, and that is said plainly rather than reconstructed from the commit log: a first session and a session that never wrapped leave identical evidence, so the tool names both instead of guessing.

plumb session wrap-up

The delta since the open, and the gate.

$ plumb session wrap-up --next 'partial refunds' --ref ORD-15 --context 'the ledger needs a split entry first'
  board     115 unproven of 199
  session   +2 proven since 2026-08-05T09:14:00+00:00
            + ORD-14
            + ORD-9
  cursor    set → ORD-15

  OK — commit .plumb/next-session.txt; the next session runs: plumb session start

The last line names what the close itself wrote — the cursor, and the series if one is declared — because a command cannot require committed a file it is about to write.

Option What it does
--project DIR the project to read plumb.toml from. An option of plumb session, so it goes before the subcommand: plumb session --project ../app wrap-up
--next TEXT the single next thing (writes the cursor)
--ref ID where it is specified — a story or an open unknown
--context TEXT the one line the next session cannot reconstruct by reading

The gate holds two things and no more: the work is committed, and the cursor's three fields are filled in. It checks that they are present, never what they mean — REF is your own string and is not resolved against anything. It does not require that the session went well — a delta may be negative, because a proof that later fails un-proves its story, and a gate that demanded progress would be one people learn to route around. Pushing is not checked either: a remote is somebody else's availability, and a gate that fails when a network does teaches people to force past it.

Exits 1 when the gate refuses, naming each reason, and 2 when plumb.toml cannot be read or no reading can be taken. plumb session start exits 0, or 2 for the same two reasons.

A refused close changes nothing. The gate is held before the board is run and before anything is written, so a refusal costs you the time it takes to read it — no cursor written, no history appended, no reading taken. Fix what it named and run the same command again; there is nothing to undo first, and the board runs once rather than twice. The delta is the one thing a refusal does not report, and it is reported by the run that actually ends the session.

What it reads and writes

Key in plumb.toml Default What it is
cursor .plumb/next-session.txt where the handoff between two sessions lives
repos ["."] which trees a session spans — name a sibling if your work touches one
series (none) where you keep a history; declaring it is the opt-in

The cursor is key="value" data. It is parsed, never sourced — the context field is a sentence somebody wrote in English, and sourcing it would execute whatever punctuation that took. Keys the tool does not know are carried through untouched, so anything your own workflow keeps there rides along.

Where series is declared, the close appends the run and reports the transitions recorded since the open — the delta says what today moved, and the history says whether today was typical. Nothing is transmitted anywhere: appending to a local store and pushing to a holder stay separate.


plumb account

An account named by your address.

plumb account open --email ADDR       # the service mails a link to the address
plumb account redeem TOKEN            # redeem the link's token; the answer comes once

The address is the credential. There is no key to generate or keep: nothing is created on your machine before you ask, and nothing is signed.

open sends the address to the service, which mails a credential link to it. redeem trades the link's token for the credential itself, once, and writes it to the enrolment file (~/.config/plumb/enrolment.json, mode 0600), where a push already looks. A file already there is kept beside it as enrolment.json.replaced. The link is spent by the answer.

Option What it does
--email ADDR open only, required — the address the account is named by
--endpoint URL the service to send to. Without it, PLUMB_ENDPOINT

Exits 0 on success, 1 when neither --endpoint nor PLUMB_ENDPOINT names a service, and 2 when the service refuses, with its answer printed, or cannot be reached.

Running a holder

Serving is a different job, and its command belongs to whoever runs one:

plumb-series-holder --store DIR [--reserve MIB] [--pool MIB]
                      [--compact-above RATIO] [--host HOST] [--port PORT]

It authenticates nobody, because it is a holder you run for yourself — there is no second party to be told apart from, so no credential, no account, no quota per holder. What it protects is a disk.

--reserve is MiB to leave free for whatever else lives on the volume. Raise it on a shared host. Below the reserve the holder refuses transmissions as retryable — the producer keeps its backlog, no board turns red, and nothing already held is discarded. Filling a shared disk is not exceeding a quota, it is taking out the neighbours; and paying for our shortage by deleting somebody's history would spend the custody the store exists to provide.

--pool is the total it may hold. --compact-above (default 2) compacts a series once it holds more than that many records per fact it currently says; a compacted series keeps, for each fact, when it last changed, and the last run marker, and drops the changes in between. See Running a holder for what each refusal means.


plumb registry

A signpost, not a command. Managing a registry belongs to whoever runs a holder, where it can actually be performed — this is the client. It says so and exits non-zero rather than failing somewhere less obvious:

$ plumb registry add-key
plumb: managing a registry is the service operator's, and this is the client.

If you are enrolling, you do not mint your own credential; the operator sends you one. If you want an account of your own, plumb account open --email ADDR.


plumb --version

$ plumb --version
plumb 0.8.1

A build published to edge between releases names the commit it was cut from — 0.8.1.dev40+5f3325a — so a report against one is unambiguous. A release names its version plainly. An install made through a channel adds the channel's name — plumb 0.8.1.dev40+5f3325a (edge) — and a pinned install or a source checkout adds nothing.

Worth knowing because the installer resolves a channel, so you never typed a version. Include it in any bug report — a report against an unidentifiable build costs the maintainer the ambiguity you saved.


See also