Changelog
0.6.0 — the debugger is gone
If you run Plumb on Java, this is the release to take. Grounding was watched by a second process attached to your suite over JDWP. It is now observed inside the process where it happens, by probes woven into the bytecode as your classes load. No port, no handshake, no attach, no second process, no timing.
| with the debugger | now | |
|---|---|---|
| a pilot's 1,928-test Spring Boot board | 2.3–5.4 hours | 41 seconds |
| that board's overhead over the bare suite | — | +1.2% |
| a 973-test suite, bare against observed | — | 93.8/93.8, 92.4/92.1, 92.2/91.4 |
| mutation-mode observation | ~90× | no measurable cost |
It is provable rather than merely cheaper. Transformed bytecode is verified by the JVM as it loads, so a malformed probe fails immediately, loudly, and the same way every time — which is how both defects in the weaver were found, one of them by a real project rather than by us.
And it is what makes a Java 8 floor possible at all. com.sun.jdi is not in the Java 8 API — it is tools.jar there, and a module needing --add-modules from 9 on — so a debugger meant a different build and launch arrangement per JVM generation. java.lang.instrument has been standard since Java 5. The instrument compiles at --release 8 and has been run on Temurin 1.8.0_504, lambda-bodied methods included. The adapter around it is still Java 17, so the floor has not moved yet; what was blocking it has.
A board you already have may read differently
Every movement is a correction, and all three run one way — toward saying less where less was known. Nothing here makes a story proven that was not.
| you had | you may get | why |
|---|---|---|
inert |
not-checked |
inert is the positive claim no production code ran, and may now only be made where a surface was declared and the instrument actually rewrote something. It used to be read from an absent hit, which an instrument that never attached produces just as well as a test that touched nothing. |
ungrounded |
dispatched |
work handed to another thread that reaches an entry point is now seen arriving, rather than inferred from a hand-off going out. Measured: 142 hand-offs seen where the debugger saw 137. |
dispatched |
grounded |
the same, from the other side. |
You do not have to remember a version to tell which side of the line a board came from. Every fact carries the instrument that made it: "mechanism": "JDWP breakpoints …" before, "bytecode probes, in process …" after. If a story moved across that line, read the mechanism before reading the story.
observation.failed now means the instrument was declared and never reached your code — there is no observer left to die. On Java the usual cause is a production class sitting under a declared test root.
Mutation was rebuilt on the same instrument
Chapter 4 mutates the code a test drove under a declared entry point. That scope came from the debugger; it now comes from the same weaver that answers grounding, and four things changed with it.
- The window follows the work across a hand-off. A system that hands work to a pool runs almost all of it on a thread the entry point never opened. The window is carried by the work — a pooled task is wrapped, a started thread is filed under itself — so an unrelated thread running production code at the same moment records nothing. On a pilot whose front door is
CompletableFuture.supplyAsync(…), this took the scope from 6 classes to 33 and its whole trade machine from invisible to mutated. - Each mutant is compiled once, not once per story that scopes it. A mutant run now names which tests noticed rather than answering did any, so one run serves every story that scopes it and each is still judged by its own citing tests. On that pilot: 3,877 story-mutant pairs, 308 distinct, 17 minutes where the same scope undeduplicated would have run about four hours.
- A survivor nobody reached is said apart from one nobody noticed. Scope is per method, so a story gets mutants on paths its tests never enter — mutants no assertion could have caught. The mutated expression now carries a probe that reports whether it evaluated. On that pilot the reported kill ratio went from 38% to 71%, and the list of things to go and fix from 205 to 109, because three quarters of the "survivors" were never reachable by the tests being blamed for them.
- Mutants that change nothing are no longer generated.
swap-returnonreturn null;produced byte-identical source: unkillable, counted as a survivor forever, capping a story below 100% however strong its tests became. Skipped now as output equals input, which is the property that makes any operator's mutant unkillable.
Survivors are named — operator, Class#method, file and line — and printed once each behind --survivors, with the manifest carrying them per story.
--mutation now requires at least one declared entry point and refuses with an explanation, rather than reporting nothing to mutate: with no entry point there is no window, and every story came up clean without having been looked at.
Expect your killed-out-of-total to move on both halves of the fraction. A ratio changing is neither a regression nor an improvement; it is a different denominator. Compare ratios only within one version.
Taking this release asks one thing of you
--json consumers must change. mutation.survivors was an integer — the shortfall — and is now the list of named mutants. The integer is mutation.survived. Anything reading survivors as a number now reads a list. There is also mutation.unreached, counting survivors no citing test evaluated.
And check the order of your Java classpath file. The guard's own remedy used to say to append your build's output directories, which yields deps:classes:test-classes while Maven's runtime order is the reverse. Both directories carry the resources Maven copied into them, so the main application.properties was shadowing the test one — on a pilot that lost a test-only Flyway location, killed a shared Spring context, and made citations read did not pass for tests that are green under Surefire. The guard now checks order as well as membership and will refuse a classpath it used to run; the remedy it prints is the whole classpath, already ordered, from your own project.
Smaller things
- A long run says where it has got to.
suite running, 340s — 812/1941 tests (41%), ~27m left, and the mutation phase counts its own mutants. A heartbeat that only repeated itself explained why a run was slow and never where it was — on a pilot that meant 9h46m with no way to estimate completion, and a correct run killed at a scheduling cap. - The board counts the tests it observed instead of listing them. On a mature suite the citing tests are a small minority, so the uncited majority was the least interesting output printed at the greatest length.
--testsrestores the list.
0.5.3 — 0.5.2's Java half, which 0.5.2 did not contain
If you took 0.5.2 for the Java fix, take this one instead. 0.5.2 is published, signed and verifiable, and the adapter inside it is the one the fix replaced. The Java source landed; the compiled jar the artifact bundles is gitignored, the release host already had one built two days earlier, and it was packaged without complaint. Everything downstream agreed with itself — the checksum matched, the signature was good, the version was right — over the wrong bytes.
The artifact now refuses to build when the jar is older than the Java it ships, and names the source that is newer. A clean git status could never have caught this: it is silent about a file git was told to ignore.
Everything 0.5.2's notes describe is true of this release, and was not true of that one.
0.5.2 — a Java board finishes
If you run Plumb on Java, take this one. A pilot's 1,928-test Spring Boot suite ran for over seven hours without producing a board. The same suite now boards in 43 seconds, against 40.7 seconds for the tests alone.
The observer was arming one JDWP breakpoint per production method, on and off, around every test — cited or not — and each of those is a synchronous round-trip to the observed JVM. On a 1,924-class surface that is 8,517 round-trips a test, and the cost grew as classes loaded: 375 tests in 18.4 minutes and still slowing down. Two things were wrong.
- Grounding is only ever recorded against a citation, so the verdict computed for an uncited test was thrown away. That project has three citations and was paying for 1,928. The production surface is now watched only for tests that cite something.
- The instrument now matches what the test does. A breakpoint charges per production method loaded; a method-entry request charges for every second it is armed. A structural test reads the artifact rather than running it, so nothing loads and there is nothing to arm; a behavioral test runs production code immediately, so the window shuts at once. The archetype you already declare in
plumb.tomlpicks which. An ArchUnit suite that cost 242 seconds under the wrong one costs 6.7.
Two defects found on the way, both of which could report something false:
- A test in the mutation scope could be one that never reached a declared entry point — an entry point carried two breakpoints over one location, so the wired window stayed open into the next test. Chapter 4 was mutating code no test had driven through the wired system.
plumb initwrote "still to do" notes into the config it was describing, so a project that named its entry points still had a file saying it had not. The notes are printed now, and not kept.
Taking this release asks nothing of you
inert means exactly what it meant, no condition code changed, and no configuration moves. If your board was correct before it is correct now, and if it was slow it is not.
0.5.1 — a signing key you can actually check
The only reason this release exists is the key. Nothing about the tool changed; if you installed 0.5.0 and it works, it still works and it is still the same code.
The key that signed 0.5.0 and every release before it has been withdrawn and published as revoked. gpg --verify on those releases now reports a revoked key with reason for revocation: Key has been compromised, and git tag -v v0.5.0 says the same. That is the revocation working, not a new fault — but it means the signature on 0.5.0 is no longer evidence of anything.
Worse, the installer served from plumbspec.org/install.sh was published the day before the rotation, so it pinned the withdrawn key. Anyone installing with PLUMB_REQUIRE_SIGNATURE=1 was having the install refused: the keyserver correctly hands back nothing usable for a revoked key, and an installer that pins a key it cannot fetch refuses rather than proceeding. That is the right behaviour and the wrong pin. This release replaces it.
Taking this release asks something of you
Fetch the new key and re-verify. The signing identity is now
208BC1279656FEEA7C2F9A1D830604D51CA7D4FB, published at keys.openpgp.org and keyserver.ubuntu.com. It is a primary key, not a subkey, so a routine subkey rotation will not break your pin again.gpg --keyserver hkps://keys.openpgp.org --recv-keys 208BC1279656FEEA7C2F9A1D830604D51CA7D4FB
- Drop the old key from your keyring if you pinned it by fingerprint. Anything still configured to require the previous fingerprint will refuse this release, correctly — it is not signed with it and never will be.
- Nothing you built or pinned needs rebuilding. 0.5.0's bytes are unchanged and still served under their own version directory. What changed is which key vouches for the current release.
0.5.0 — the methodology leaves, and Plumb stops reading specs
The largest change is a position, not a feature: Plumb does not read a spec, verify one, or check against one. Everything that did has moved out or gone. If you adopted Plumb for the board, nothing you rely on changed. If you adopted it for the spec gates, they are still available — as a separate product you install yourself.
Taking this release asks something of you
- Every account created before 0.5.0 is void. Signing keys are derived under a label now, so the same passphrase produces a different key and the receiver does not recognise it. Run
plumb account createandplumb account claimagain. Nothing is lost, because nothing was held under the old one. Running a matrix? SetPLUMB_KEY_LABELper leg so each derives its own key from the one secret, and register each label once — two legs sharing a key race for one counter. - A
plumb.tomlcarrying both a top-leveladapterand[[adapters]]blocks is now refused. The top-level line used to be silently dropped, so a project declaring two languages got a board covering one — and a board covering half a project reads exactly like a board covering it. If yours has both, that refusal is why, and it means an earlier board may have been quieter than it looked. Move the top-level adapter into a block of its own. plumb init --specis gone;initwrites onlyplumb.toml. Anyspec/directory it wrote before is yours and is untouched. What changed is the position behind it: those gates were never ours to keep correct.status.py,conventions.py,flowcheck.py,test_spec_backlog.pyandtest_spec_status_guardrail.pyare yours to keep, edit or delete. If your project is not Python, deleting them is the point — they were six Python modules and a second test runner in a repository with no Python in it.plumb specis gone. Checking a spec means first deciding what a story is and where it lives, which is choosing your method for you. Those gates ship as goldspec now — a separate product, its own version, installed and run on its own:curl -fsSL https://plumbspec.org/gold/install.sh | sh goldspec <spec-directory>
It is optional and Plumb never asks whether you have it. If you ran
plumb spec checkagainst a spec in goldspec's conventions, that is the replacement and the checks are unchanged — what changed is whose they are.plumb catalogueis gone, and nothing replaces it. It derived what a release changed by diffing two story feeds, and the feed is gone: a set of subjects handed to us is a copy of a list your project already owns, stale the moment yours moves, with nothing able to detect it. If you used it for a changelog, that is yours to write now. We are in the same position and are not pretending otherwise — this statement is hand-authored, which the spec records as an open unknown against the story requiring it to be derived, not as a thing we think is fine.
New
- Three condition codes, and two fire on stories whose tests are green.
grounding.not-wiredandgrounding.inertname a cited subject whose citations passed and which is still unproven — the first because nothing it ran reached the wired system, the second because nothing it ran touched the implementation at all.structural.executedis the same for a structural subject whose citing test executed production code. Nothing changed status: these were already unproven, and the board simply carried no reason. On our own spec that was 52 subjects, 11 of them inert. If you consume--json, expect codes you have not seen; a check treating an emptyconditionslist as no problem here will now see an entry, and that entry is the point. - Condition prose resolves offline.
evidence.none,evidence.failedandaspects.per-aspectused to be emitted withwhat,next_stepandexamplepresent and empty. If you built a lookup table of your own or went to the site to find out what a code meant, delete the workaround — the installed tool has always had the text and now hands it to you. plumb session— open from a cursor, close by measuring the session against the board. Opt-in; nothing else in the tool depends on it.- A refused
session wrap-upchanges 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. It used to run the board, append to your series and write the cursor first, so satisfying the gate meant a second full board run over a cursor already written. plumb account— an identity nobody has to issue, derived from a passphrase rather than stored, so an ephemeral CI host can be handed a string instead of a file.account showanswers whether it can still sign, offline, rather than reporting healthy by construction.- Two channels.
stableis what an unqualified install gives you;edgeis the candidate, published so it can be tested before it is recommended. Both pointers are signed, because an installer that verifies the download and takes the choice of download on trust has authenticated the wrong half.
0.4.1
Fixed — a spec written by plumb init --spec could not run.
If you had the error
ModuleNotFoundError: No module named 'flowcheck'
!!!! Interrupted: 1 error during collection !!!!
Run
plumb init --spec
0.4.0 — the series, decisions and parameters
plumb series— a history beside the board: when a subject last changed, when it was last looked at, and which subjects move most. Kept locally, and optionally pushed to a holder without the run depending on it.- Decisions — record a choice together with the condition that would make it wrong, and its standing is derived rather than typed. One running past its own condition, or one nobody has looked at in a year, shows up instead of reading as settled.
- Parameters — a threshold or a window is a decision with a number in it. Where you can state a model and a measurement for it, its standing derives too — supported, unvalidated, refuted — so a constant nobody has validated stops looking like one that was.
- Subjects that are never done — cite one with
informsinstead ofproves, and it derives in its own words: holding, breached, silent, unwatched, rather than borrowing a story's proven. A parameter reads supported, unvalidated, refuted, unmeasured. - Aspects — a subject may make several claims and gets a verdict for each, with no aggregate to collapse them.
unprovennow says which kind it is —evidence.nonewhen nothing cited it,evidence.failedwhen a citing test ran and failed,evidence.not-observedwhen it was cited and nothing ran. They were one row before.- A declared set, handed over as data — CSV or JSON, read by the core, so the board shows what is left beside what is proven. This is the only way to get that in a language whose spec your test runner does not execute.
plumb board --scope— one board, scoped to one namespace.plumb manifest migrate— carries an older manifest forward; the chain reaches two majors back.
0.3.10 — two defects the suite could not see
- A test session that dies while collecting — an import error in one test file — used to be reported as a healthy run in which nothing was proven. It exits 2 now, because a board that could not be derived is a different thing from a board with nothing on it.
- The artifact could not ground a project that shares its own package name.
0.3.9 — a Java run no longer needs a Python test framework
0.3.8 — a run that observed nothing exits non-zero, and the codes are documented
- A run that discovered no tests now exits 3 with
observation.none, where it used to print an empty board and exit 0. This is the difference between a CI step that passes when it saw nothing and one that fails, so it is worth checking before you upgrade. - The return codes are documented, and held equal to the tool by a check.
0.3.7 — the Java adapter runs on a JVM that is not the build host's
- The adapter ships at a declared bytecode floor, Java 17, instead of whatever JDK happened to build it.
- A broken Plumb install no longer presents as a broken project.