Testing¶
What is tested, with what, and what is deliberately not tested. The last table is the important one: a testing document that only lists successes is a marketing page.
Everything here runs offline, with no API key and no network. That is not a convenience — it
is a constraint the project keeps on purpose, because a test suite that needs a provider is a
test suite that stops running on the day the provider has an incident. Where a provider is
needed, docket.fakeupstream is a real HTTP server on loopback that speaks the same wire format
and can be told to fail on cue.
The suite¶
$ make test # 1205 tests, no network (2-4 min on a 2-vCPU box)
$ make check # + ruff, + bandit, + the independent Node verifier
| file | tests | what it pins |
|---|---|---|
test_canon.py |
44 | canonical JSON, Merkle trees and proofs, the no-floats rule |
test_vectors.py |
11 | the committed conformance vectors, including that regenerating them changes nothing — and that the generator actually wrote where the test is looking, which it did not for as long as that check existed |
test_conformance.py |
57 | the negative corpus is a claim about the format, and this holds the claim: it rebuilds every vector, runs it, and fails if any is accepted or refused by a rule the vector does not name — plus the verifier defects writing the corpus found (an enum read as free text, a duplicate JSON name, a re-binding folded into a proof, a ledger that was repaired instead of refused, and a per-model breakdown that could name a model nobody called) — plus two canaries proving the runner can fail: a rewritten expectation must produce wrong-check, and a vector with no expectation must be reported rather than silently skipped |
test_policy.py |
14 | accept and reject paths for every policy key |
test_pricing.py |
19 | price table, cost arithmetic, the missing-model path, ceilings |
test_ledger.py |
18 | append/read, chain resumes, ceilings, unwritten bookkeeping |
test_ledger_contracts.py |
35 | the ledger's caller-facing contracts: event dicts, byte accounting, ceilings, a file that grew behind the writer, the lock file, the repair sidecar |
test_ledger_faults.py |
29 | a ledger on a failing filesystem: ENOSPC, EIO, a closed handle, an OS error while opening |
test_ledger_concurrency.py |
4 | one ledger, eight writer threads: order, chain, agreement, file digest |
test_latency.py |
21 | the percentile rule, hostile row values, the verifier's recomputation, the report's tail line |
test_receipt_verify.py |
121 | the receipt and every verification check, plus one name to one verdict |
test_proxy.py |
16 | pass-through, streaming, policy enforcement, what is never recorded |
test_cache.py |
74 | the response cache: what may be stored and what may never be, hit/miss/TTL/LRU/ceiling behaviour, the key covering the request as sent, an open breaker not blocking a hit, concurrency, every receipt-level attack on a saving, and twenty-five tests written to kill survivors of a mutation sweep — nine for the cache itself and sixteen for the verifier that recomputes a stated saving |
test_proxy_concurrency.py |
6 | parallel calls through the real boundary: chain, totals, limiter, breaker, cap |
test_resilience.py |
60 | breaker, limiter, fault injection grammar, evidence guard |
test_supply_chain.py |
10 | what the package installs and what it imports: dependencies = [] read from the manifest, the three extras by name, and an AST walk asserting every import outside the standard library resolves to a declared, guarded extra |
test_explain.py |
13 | analysis rules and their attributed impact |
test_supervisor.py |
11 | the run lifecycle, exit codes, the evidence directory exclusion |
test_snapshot_restore.py |
26 | snapshot, plan, apply, the CLI, idempotence, the restore record |
test_snapshot_confinement.py |
25 | hostile documents: paths, digests, modes, symlinks, keys |
test_fsdiff.py |
5 | what the file diff sees, with a relative and an absolute cwd |
test_invariants.py |
16 | executed properties: one writer per ledger, 200 events from 8 threads, no deduplication of a replayed call, socket and thread release, a SIGKILLed agent, a clock that steps forwards or backwards |
test_chaos.py |
34 | the fault grammar, audit_run, the game-day phases (including the cache brake), the reference agent repeating itself on request, and the independence of the phases' policies from one another |
test_security.py |
40 | red team: forgery, injection, bypass, confinement, secret capture |
test_cli_input.py |
51 | numeric flags: every cap, every way to spell it wrong |
test_cli_sdk_demo.py |
25 | the commands end to end, the SDK, the demo |
test_examples.py |
10 | the five examples executed rather than described: the quickstart and the SDK program run offline and the receipt each one prints is then verified with the command each one prints, one digit of the quickstart's ledger is edited and the same verification is required to fail, both policy files are accepted by a real supervised run, and every README's links, repository paths and Python files are checked |
test_i18n.py |
16 | locale independence, Unicode round trips, non-ASCII paths |
test_url_guards.py |
43 | the two URL guards, and a syntax-tree walk that every urlopen uses one |
test_diagnostics.py |
8 | the self-test's probes against a working installation |
test_signing_and_attach.py |
8 | HMAC sealing, Ed25519 signing, agent-asserted events |
test_render_golden.py |
3 | the rendered receipt, report and diff, against golden files |
test_loadtest.py |
37 | the load harness: refusal classification, the pacing schedule, the RSS sentinel, and the orchestrator refusing to call a self-inconsistent run evidence |
test_deploy_check.py |
44 | the static deployment checker, one broken fixture per invariant it claims to enforce, the number formatting Helm actually produces, and the ConfigMap value that arrived as a float — a checker that cannot fail is decoration |
test_mutate.py |
40 | the mutation harness itself: the splice is a byte-for-byte no-op, every mutant changes exactly one line and compiles, a site on a line with non-ASCII text splices by character not by byte (the regression that hid 82 of verify.py's 177 sites), a site at byte zero is a candidate rather than a drop, every operator is exercised and labelled, the docstring rule answers is True/is False when it is asked directly, the verdict maps pytest's exit code to survived/killed/killed-import/timeout, timeouts stay out of the score, --only-line runs every mutant on a line and refuses a line with none, a dropped site is counted rather than lost, a sandbox that cannot be deleted does not replace the report or any of the three refusals beside it, and the CLI writes both reports, re-runs into a directory it already made, and decides its exit code against --min-score inclusively |
test_helm_fidelity.py |
17 | the comparison between the checker's chart render and a real Helm render: pairing by kind and name, the block scalar's final newline, the path of the first difference, exit 2 when Helm is absent, and — where Helm is installed — the end-to-end comparison |
test_doc_links.py |
25 | every docs/…md reference in the tree resolves, every page is in the nav, the scanner itself can fail, a staged site copy is not read as a second copy of the documentation, this table's own counts are reconciled against the suite, the counts written beside a test filename in the other pages are reconciled too, the size the README quotes for the whole suite is reconciled, every pytest command the Makefile and the CI jobs ship prints its counts and its skips, no document states a position inside a source list that does not publish one, every row of a graded table carries its grade, every make target the workflows and the help text name exists, every flag docs/cli.md documents is an option of the command it is filed under, and no document outside the raw measurements carries a path from the machine that wrote it, every source line cited in prose (verify.py:194) exists and carries a site the mutation harness would mutate, and the coverage maps have a row for every concept and practice they claim |
test_a11y_check.py |
71 | the site checker against pages built to break each rule it applies, and against pages built to be clean: every rule proves it can fail, every way a link or a control can be named proves it does not cry wolf, and eighteen of them were written to kill survivors of the checker's own mutation sweep (98.5%, mut-a11y-release.json) — the ones that assert what the verdict cannot show: a name, its source, a line number, the tree the parser built |
test_fuzz_regressions.py |
98 | one test per defect a fuzzer found (see below) |
Counts are collected (pytest --collect-only), not remembered — and "they are checked" is now a
property rather than a claim: tests/test_doc_links.py::test_the_counted_test_table_in_the_docs_matches_the_suite
runs the collection and fails on any row that disagrees with it, or on a test file the table has
forgotten. (The sentence here used to say the CI run checked them. Nothing did. A number in a table
is exactly the kind of statement that goes stale silently while every test stays green, which this
repository has already demonstrated once with a conformance vector.)
Two options are not required to use Docket — the core has zero runtime dependencies — and the suite says so by skipping with a reason rather than failing, because a bare clone that goes red looks like a broken project:
The counts in that table come from pytest --collect-only -q (how many tests exist) and from a run
with -ra (how many passed, and why the rest skipped) — never from a log a script had to guess at.
The release checklist quotes counts collected at a commit rather than a number copied out of a log for
the same reason, and the verbosity trap that makes the difference is recorded under the table.
| installed | result | the skips |
|---|---|---|
pytest alone (make test) |
1,106 passed, 47 skipped | tools/deploy_check.py's checks (need PyYAML); the chart comparison reads the chart's values.yaml — PyYAML is what these cells lack. The Helm client itself is installed on the machine that ran this matrix (see below); the Ed25519 sealing and signing tests (need cryptography) |
pip install pytest cryptography |
1,111 passed, 42 skipped | tools/deploy_check.py's checks (need PyYAML); the chart comparison reads the chart's values.yaml — PyYAML is what these cells lack. The Helm client itself is installed on the machine that ran this matrix (see below) |
pip install pytest pyyaml |
1,148 passed, 5 skipped | the Ed25519 sealing and signing tests (need cryptography) |
pip install -e '.[dev,sign]' (the documented dev setup) |
1,153 passed, nothing skipped | none |
Each cell is a fresh virtual environment with pip install pytest plus the extras its row names, then
pytest tests -ra from the repository root. All four cells collect the same 1,153 tests: what
differs between them is whether a test runs or skips, which is what the last column is for. -ra is
what makes that column checkable — every skip is a sentence with its reason, and a cell that skipped
something without saying why would be indistinguishable from a cell that never had the test.
Two measurement defects are recorded here because both produced a plausible number rather than an
error. The 2026-10-15 version of this table recorded 0 skipped in every cell, while the four runs it
summarised had reported their skips as 47, 42, 6 and 1 — the counts had been summed with paste | bc,
and bc is not installed everywhere; the sum is awk now, and each cell's counts are checked against
that cell's own summary line before they are quoted. The second is verbosity, and this page stated it
wrongly until 2026-10-16: it said the counts come from a run with -ra and "never a -q run's last
line", as if -q were the problem. The flag that matters is how many times it is passed.
pyproject.toml's addopts already carry one -q, so a command line that adds another asks for
-qq — at which level pytest 9 prints no summary line at all, and a script that greps for one
records zero passes for a suite that passed a thousand. A single -q prints it, and that is measured
rather than recalled — 16 passed in 10.26s beside the progress line of this page's own test file,
-ra on top of the addopts flag. The extra flag was in the mutation harness's own pytest line, in
the release check's, and in the five commands this repository ships for other people to run — make
test and the four CI jobs — where it is quieter and was therefore invisible longer: a green job with
no line in its log saying how many tests passed. All five run pytest tests -ra now, and
tests/test_doc_links.py reads addopts and fails on any shipped command line that adds a second
-q or drops -ra, so the sentence above is a test rather than a convention.
The skips are not a way of hiding a defect: the checker these 30 cover exits 2 with a reason
when PyYAML is absent — "could not run", a different code from the 1 that means "found a problem" —
and tests/test_deploy_check.py includes the test that pins exactly that, on a machine that has
PyYAML, by shadowing the import rather than uninstalling anything.
The round tabulated above ran at 2026-10-16T06:45:30Z, and it is the fourteenth measured on this tree. The
rounds before it are filed rather than overwritten — in the mutation/ directory of the workspace that
produced these documents, which is not part of this repository — because a matrix someone might check
is evidence and a matrix edited in place is not. Until 2026-10-16T17:15Z the filing was a manual step
taken between rounds, and one round was lost to it: the 16:49:21Z cells were overwritten by the next
round's own first command, which is why the table below carries their numbers with nothing behind them
but the run's output. The script files the previous round before it clears the directory now, and the
table names every round by the stamp that round's own summary wrote, which is also a correction: the
earlier prose on this page labelled three rounds by the time their directory was filed rather than by
the time they ran. The newest, 06:45:30Z, measured 1,106 / 1,111 / 1,148 / 1,153 with 47 / 42 / 5 / 0 skipped. Every round, and what its machine had:
| round (start, UTC) | tests collected | passed per cell | skipped per cell | the environment, and what the round was |
|---|---|---|---|---|
08:05:28Z |
1,024 | 977 / 982 / 1,017 / 1,023 | 47 / 42 / 6 / 1 | the first round kept here; the + PyYAML cell exited 1 on the wall-clock flake in tests/test_snapshot_restore.py |
08:21:47Z |
1,025 | 978 / 983 / 1,019 / 1,024 | 47 / 42 / 6 / 1 | the same tree an hour later, the flake not repeating |
10:39:50Z |
1,027 | 980 / 985 / 1,022 / 1,027 | 46 / 41 / 5 / 0 | cells red: the comparison test had one gate and two prerequisites |
10:54:56Z |
1,027 | 980 / 985 / 1,022 / 1,027 | 47 / 42 / 5 / 0 | the gate and the prerequisite both in place |
14:06:58Z |
1,028 | 981 / 986 / 1,022 / 1,027 | 47 / 42 / 6 / 1 | taken with the Helm client off the machine |
14:45:28Z |
1,029 | 982 / 987 / 1,024 / 1,029 | 47 / 42 / 5 / 0 | the Helm client back on the machine |
15:04:24Z |
1,031 | 984 / 989 / 1,026 / 1,031 | 47 / 42 / 5 / 0 | the two document guards of that hour added |
15:21:56Z |
1,033 | 986 / 991 / 1,028 / 1,033 | 47 / 42 / 5 / 0 | the release record re-based onto them |
16:49:21Z |
1,077 | 1,030 / 1,035 / 1,072 / 1,077 | 47 / 42 / 5 / 0 | the round whose cells were overwritten: the next round cleared matrix/ before filing it, so its numbers survive here and in the run's own output and nowhere else. tools_matrix.sh files the previous round now |
17:15:10Z |
1,076 | 1,029 / 1,034 / 1,071 / 1,076 | 47 / 42 / 5 / 0 | one test fewer than the round above, because the release-record wiring assertion was moved out of the repository (see below) |
17:39:26Z |
1,079 | 1,032 / 1,037 / 1,074 / 1,079 | 47 / 42 / 5 / 0 | one round older than the round the documents describe |
19:09:03Z |
1,105 | 1,058 / 1,063 / 1,100 / 1,105 | 47 / 42 / 5 / 0 | one round older than the round the documents describe |
20:19:09Z |
1,116 | 1,069 / 1,074 / 1,111 / 1,116 | 47 / 42 / 5 / 0 | one round older than the round the documents describe |
06:45:30Z |
1,153 | 1,106 / 1,111 / 1,148 / 1,153 | 47 / 42 / 5 / 0 | this round — the tree these documents describe |
A skip count is only evidence when the environment that produced it is stated, which is why each round
is named and what its machine had is written beside it. All of them describe this machine except the
14:06:58Z round, which was taken with the Helm client off it. Every round is four fresh virtual
environments, pip install pytest plus the extras the row above the table names, PYTHONPATH=src,
pytest tests -ra from the repository root, and a separate --collect-only -q for the collected
count.
Three of the earlier rounds were quoted with a derived number before they ran, and all three times
the derivation was wrong — by one, by two, and by two again: a re-base by hand is a prediction, and the
cells are the measurement. That is the same lesson the table itself records about paste | bc — the sum is a
command now, or it is a guess.
The matrix is also where a defect was found, in the same family as the one this page already records
for tools/deploy_check.py: the 10:39:50Z round is the one in which the comparison test showed that it
had one gate and two prerequisites. It skipped when the Helm client was missing, and with the
client reachable and PyYAML absent — rows 1 and 2 above — it ran and failed on the tool's PyYAML is
not installed exit 2, a red suite for a setup the README describes. It carries a second gate now, in
the same idiom as tests/test_deploy_check.py's, and the round in which those two cells were red is
kept beside the green one.
By kind¶
| kind | how it is done here |
|---|---|
| unit | per-module files above; the pure functions (canon, pricing, policy) are tested directly |
| integration | test_proxy.py / test_supervisor.py: real loopback HTTP, real subprocess, real files |
| system | docket demo, docket selftest (19 probes against a real boundary in one process) and docket game-day (every failure phase, with a post-mortem) — all three run in CI on every push — plus the clean-venv install of the wheel: the whole thing, as shipped |
| acceptance / UAT | the transcripts in the README and docs/cli.md are captured from real runs, not written by hand; tools/transcript_run.py reproduces them; and the example READMEs are checked against live runs by tests/test_examples.py, which asserts the quickstart's own numbers, edits one digit of its ledger, and requires the verification it prints to fail |
| functional | every documented feature has a test named after the claim it makes |
| non-functional | concurrency, locale, fault tolerance and cost; see the other tables |
| smoke | CI installs the wheel into a clean venv and runs docket demo, asserting VERIFIED appears; the suite also runs the quickstart and the SDK example end to end (tests/test_examples.py) |
| sanity | docket doctor and docket selftest (19 probes) before anything else, when something looks wrong |
| regression | test_fuzz_regressions.py: 98 named tests, each reproducing one defect that was found and fixed |
| end-to-end (E2E) | tools/transcript_run.py: supervisor → proxy → child agent → ledger → receipt → verifier, offline |
| performance / load / stress | tools/loadtest.py, which drives a real supervised run under load (2,000 calls over 8 threads, and a paced soak), plus the game-day rate-limit and ledger-pressure phases and the concurrency files — numbers below |
| endurance / soak | tools/loadtest.py --seconds: a paced run against a real boundary, RSS sampled once a second — ten minutes is the longest run in the suite (make soak). Bound in the section below |
| spike | test_proxy_concurrency.py: a burst of parallel calls against a one-call cap |
| volume | the game day's ledger-pressure phase grows a ledger to its ceiling and asserts the run stops rather than dropping events |
| scalability | one process, threads rather than workers; the ledger is the only shared resource, serialised deliberately by one append lock — and the load point is where that serialisation becomes a number |
| security | test_security.py (red team) + bandit in CI + the URL-guard walk + tests/test_supply_chain.py (the manifest and the imports agreeing) + pip-audit over the development toolchain on a schedule (.github/workflows/security.yml): no known vulnerabilities as of 2026-10-15 |
| penetration | the red team's forgery cases: a doctored receipt, a doctored ledger, a doctored manifest, a path traversal, a setuid mode, a swapped blob |
| usability | the transcripts are the usability test; a change that makes the output harder to read shows up in the golden files |
| accessibility | two artefacts. The terminal output is plain UTF-8 box drawing with a NO_COLOR path, and the machine-readable form (--json, the markdown report) carries the same content, so nothing is only conveyed by colour. And the built documentation site — the only HTML this project publishes — is checked by tools/a11y_check.py against ten mechanical rules (language, title, one h1, no skipped heading level, a single main landmark, alt present on every image, an accessible name on every link and every rendered form control, a header cell or caption on every table, no duplicate id): tests/test_a11y_check.py makes every rule prove it can fail and every path by which a link or a control can be named prove it does not cry wolf. The name precedence is the accessible-name computation of AccName 1.2 §4.3.2 — aria-labelledby, then aria-label, then a control's <label>, then the element's content, then the title attribute, which the specification marks as a last resort — and each run prints where the names it accepted came from, with the split in the JSON report (names_by_source), so the number a text-only rule would have got wrong is a line in the record rather than a claim beside it |
| compatibility | CI matrix: Python 3.11, 3.12, 3.13; Node 20 for the independent verifier |
| reliability | chaos.py + the nine game-day phases; a broken ledger tails off, a full one is declared |
| the practices rather than the kinds | docs/sre-coverage.md: 22 resilience practices — stress, fuzzing, chaos monkey, game days, red teaming, synthetic monitoring, FMEA, rejuvenation, post-mortems (postmortems) — with the status of each |
| maintainability | ruff (lint and format, pinned to one version in both the dev extra and CI: the formatter rewrites different files in 0.17 than in 0.6.9), bandit, and the seam rule in CONTRIBUTING (each claim has one test file) |
| localisation / localization / i18n | test_i18n.py: identical digests under C, de_DE, tr_TR, ja_JP; NFC ≠ NFD; non-ASCII paths round-trip |
| black-box / white-box / grey-box | black: the CLI and the JS verifier; white: internals such as _open_ledger_handle; grey: the red team, which reads the code and then attacks the interface |
| manual | the game-day post-mortem is read by a person; the record it writes is for that person |
| automation | make check; CI runs the same on every push |
| exploratory | the defects in "Found by testing" below were found by trying things, not by a plan |
| ad-hoc | docket chaos --fault … and tools/fuzz.py --target … are the ad-hoc entry points |
| monkey / gorilla | docket game-day kills the supervisor with SIGKILL mid-run and asserts what survives |
| boundary-value | every cap: 0, 1, exactly at the limit, one over; every ceiling in pricing/policy |
| equivalence partitioning | the fault grammar, the policy validators, the URL guards (loopback vs not, scheme by scheme), and the cache key (same body vs differing field, stream vs non-stream) |
| mutation | tools/mutate.py: seeded single-site mutation of canon.py, verify.py, ledger.py, latency.py, loaddriver.py, cache.py and resilience.py, plus the Helm-fidelity tool and the harness's own tests (it scores itself with the file you are reading), with every survivor classified — see Mutation testing |
| static | ruff check, ruff format --check, bandit -q -c pyproject.toml -r src tools |
| dynamic | tools/fuzz.py: six property-based targets, time-boxed, seeded and reproducible |
| alpha / beta | not applicable yet — no release channel beyond the source tree |
| A/B | not applicable — nothing here is a user-facing choice to optimise |
| recovery | the game-day rollback phase, test_snapshot_restore.py, and the torn-tail recovery path |
| cache invalidation | three ways, all counted and all tested: ttl_seconds expiry (checked on lookup, entry dropped), LRU eviction at max_entries, and the refusal to store a response the provider did not send (error, truncated stream, injected fault). The one that is policy rather than mechanism is max_hits, which stops the cache answering so the run starts paying again |
| configuration | test_policy.py (accept/reject), plus the game day running with mutated policies |
| installation | CI: pip install --no-index --find-links dist docket into a fresh venv, then docket --version and docket demo |
| visual | test_render_golden.py: the receipt, the markdown report and the diff, byte-for-byte |
| API | the boundary's HTTP surface (test_proxy.py) and the SDK's event API (test_cli_sdk_demo.py) |
| database | not applicable — there is no database; the ledger is a JSONL file by design (ADR 0001) |
Measured: throughput, latency and memory¶
tools/loadtest.py generates load the way the boundary is actually used — a supervisor process, a
boundary inside it, a scripted provider behind it, and an agent child in front — and then reads the
evidence back. Its full reports, including the host line, are in
docs/measurements/2026-10-15-2core/; the table here is a
summary of the same files, and every number in it was produced by the command beside it.
| run | shape | calls | wall clock | throughput | client p50 / p95 / p99 | boundary RSS | receipt |
|---|---|---|---|---|---|---|---|
| load point | make load — 2,000 calls, 8 threads, unpaced |
2,000 | 11.368 s | 175.93 calls/s | 44 / 48 / 53 ms | +4,188 KiB | verifies |
| soak | make soak — 12,000 calls at 20/s for ten minutes |
12,002 | 600.166 s | 20.00 calls/s (paced) | 4 / 5 / 7 ms | +27,180 KiB | verifies |
| soak control | the same rate, half the time | 6,002 | 300.083 s | 20.00 calls/s (paced) | 4 / 5 / 8 ms | +13,720 KiB | verifies |
Three things this table says, and two it does not:
- The recorder's cost is the write, not the CPU. Unpaced, 8 threads push
175.93 calls/s through the boundary on 2 vCPUs, with a client p50 of
44 ms — an order of magnitude more than the paced run's 4 ms, because under
concurrency every event waits behind the append lock and its
fsync. That is the design decision in ADR 0005 showing up as the only throughput number the tool has. - Nothing drifts for ten minutes. 12,002 calls, 0 refusals, 0 errors, 0 dropped events, and the receipt from the loaded run verifies afterwards with the same checks a quiet run passes.
- Memory tracks events, not time. Ten minutes added
27,180 KiB and half the duration added
13,720 KiB: the same ~1.1 KiB per recorded event in both, which is
the in-memory event list the Merkle root and the receipt are derived from — bounded by the run's
own
max_eventsandmax_ledger_bytesceilings rather than by a timer. - It is not a provider benchmark. The upstream is scripted and local. Nothing here says anything about a real model API's latency, and the reports say so themselves.
- It is not a load curve. One concurrency point, one paced rate, one machine. No saturation knee, no scaling plot, no claim about another host.
The harness is tested the same way everything else is: tests/test_loadtest.py asserts that a
dropped call, a missing latency, a disagreement between the client's count and the ledger's rows, a
receipt that no longer verifies, or a ledger that does not hash to the receipt's own claim all make
a run not usable as evidence. A load tool whose output cannot be wrong would be a marketing
statement, not a measurement.
Reliability and chaos¶
| phase | what it does | expectation |
|---|---|---|
baseline |
a well-behaved agent, nothing broken | every call recorded, receipt verifies |
upstream-failure |
the provider fails some calls and serves others | failures are model.error, never model.response; denials are refused before forwarding. The audit's breaker check skips here (the fault rate never reaches the threshold) and says why — its condition is exercised by the circuit-breaker phase, and tests/test_chaos.py pins that a conditional check never reports a vacuous pass |
circuit-breaker |
the upstream fails repeatedly | the breaker opens, refuses by name, records the opening |
upstream-latency |
slow responses | latency is recorded, the run still finishes or stops legibly |
rate-limit |
a fast agent against max_calls_per_second |
exactly the budget is admitted, each refusal is recorded, HTTP 429 |
ledger-pressure |
a ledger driven to its event ceiling | the run stops with exit 42 rather than dropping events |
rollback |
seeded files, a damaging agent, a cap breach | the files are byte-identical afterwards, the rollback is in the receipt and the ledger, the run's own evidence survives |
cache-brake |
a repeated loop (DOCKET_AGENT_MODE=loop) against a response cache with max_hits = 2 |
the hits are in the ledger as events, the ceiling is recorded and named in the receipt, and the calls after it reached the provider again — the one phase whose subject is a number rather than a failure, so verification recomputes the saving and the spend still describes money actually spent |
supervisor-killed |
SIGKILL mid-run | the ledger up to the kill is readable and classified, no receipt is claimed, recovery is stated |
docket game-day writes a post-mortem beside the evidence, including expected-vs-actual per
phase, and exits non-zero if a phase misses its own stated expectation. The last recorded full
run: 9/9 phases, ~20 s, offline.
Found by testing¶
Each of these was a real defect in a shipped feature, found by an activity in the table above,
with a named regression test in test_fuzz_regressions.py or the relevant file:
| found by | defect |
|---|---|
| fuzzing (6 targets, 13 findings) | canonical-form and verifier edge cases: float acceptance, digest casing, proof path handling, huge inputs |
| red teaming | a CONNECT tunnel that skipped egress checks; a secret reaching the ledger through a header; a receipt that verified after a field edit; a cap bypassed by a request shape |
| game day | evidence-directory self-recording; an absolute-path diff under a relative cwd; a rollback reported as applied when every path was refused |
| concurrency tests | a ledger written out of sequence order by parallel calls (the tool accusing its own run of tampering) |
| input testing | --max-usd nan silently disabling the cap; --max-usd abc printing a decimal exception's args |
| repeating an action | docket restore failing on the second run and blaming the operator for Docket's own edit |
| writing an end-to-end probe for a feature already tested | a response cache handed to the boundary was silently discarded while it was empty — ResponseCache defines __len__, so cache or ResponseCache(...) replaced the caller's enabled cache with the policy-built one, and the boundary did no caching at all. Forty-one unit tests could not see it: every one of them configured the cache through the policy, and the defect only appears while the cache is empty. Fixed at both ends and pinned by a regression test that passes a cache as an argument, the way a host application does |
| reading the site navigation | mkdocs.yml listed two ADRs twice (0002 and 0003 again after 0005): the pages resolve, so no strict build fails, and the menu simply shows the same entry twice. The link checker now asserts every document is in the navigation |
| the conformance check | spec/vectors going stale after a policy change (the digest bound into every event) |
| re-reading the spec against the implementation | NaN/Infinity are read by Python and rejected by JSON.parse: a request body carrying one became 502 docket internal error in the run's own evidence, a ledger line carrying one was reported as "cannot be canonicalised" instead of "not JSON", and a hand-written Infinity on the last line claimed the torn-tail crash-recovery path |
| reading the artefacts the way CI would | mkdocs build --strict aborted on this repository — the documentation links to tools/deploy_check.py and tools/loadtest.py, and the staging step that feeds mkdocs did not copy .py files, so the docs job would have failed for anyone who enabled it. The staging step carries them now, and a test stages the tree and resolves every relative link in the docs against it, without needing mkdocs-material installed |
| installing the documented dev setup | pip install -e '.[dev,sign]' pulled the newest formatted, while CI pins 0.6.9 — and the two disagree about seventeen files of this tree, so a contributor's make lint could fail on files CI calls formatted. The extra pins the version CI installs |
| writing a probe for a feature end to end | docket selftest's new cache probe drives two identical calls through a real boundary and found that a cache supplied to RecordingProxy was silently ignored while it was empty: ResponseCache defines __len__, so the boundary's cache or ResponseCache(...) replaced the caller's configured, enabled object with a policy-built one. Every unit test passed a cache through the policy, so none of them could see it, and the failure appeared or vanished with the cache's contents. Fixed at both ends (is not None, and __bool__ so an empty cache is still a cache), with a regression test that supplies one as an argument |
| checking the manifests the way a cluster would | the chart rendered by Helm itself (v3.16.3) produced max_ledger_bytes = 6.7108864e+07 where the static checker produced 67108864: Helm hands every template a float64 for each number in values.yaml, and Go prints a large one in scientific notation, which TOML reads as a float. Docket's loader accepted it, so nothing in the tree noticed — the divergence was invisible because the checker rendered numbers the way Python writes them. The chart's integers now go through \| int64, the checker renders values the way Helm does (pinned against a table produced by running Helm), it refuses an integer key that arrives as a float by name, and the two renderers now agree document for document (deploy/README.md) |
| installing the package the way a user would | tools/deploy_check.py needs PyYAML, and the suite around it failed — 28 tests, not skipped — when it was absent, so a bare clone was red while the README promised a green suite (pip install -e '.[dev]' did not pull it in either). The same tool also exited 1 when it could not run, which is the code it uses for "found a problem"; its own docstring promised 2. Both are fixed, and the exit code now has a test that blocks the import rather than uninstalling anything |
| checking the release the way a publisher gets it | the FMEA names docs/measurements/2026-10-15-2core/load-report.json as what catches tail latency, that directory's README and docs/production-coverage.md name the other reports, and .gitignore carried a bare load-report.json line — which ignores that name anywhere in the tree. The two reports were in the authoring checkout and in no clone: the suite was green where it was written and red the first time the release checks ran against a clone of the bundle instead of the working directory, which is what that check is for. The rules are anchored now, the reports are tracked, and two tests hold the property — one scans every file the documentation names, the other feeds the scanner the rule that shipped and requires it to fail |
| running the dependency matrix with the Helm client installed | tests/test_helm_fidelity.py's end-to-end comparison had one gate and two prerequisites: it skipped when the Helm client was missing (shutil.which("helm")), and the moment the client became reachable — this round put it on the path the mutation harness's sandbox uses — it ran in the two cells without PyYAML and failed, on the tool's own PyYAML is not installed exit 2, where every other test in the file asks for a yaml fixture and skips with its reason. A user following the README with Helm installed and no deploy extra got a red suite for an environment problem. The same defect tools/deploy_check.py had, in the same file family: the test now declares both prerequisites |
| re-reading what the commands the repository ships actually print | make test and four CI jobs ran the suite with -q on a command line whose addopts already carry one — so pytest was asked for -qq, where it prints the progress line and no counts line, and the jobs went green with nothing in the log saying how many tests passed. Found while correcting the same defect in the mutation harness's control run and in the release check, where it was load-bearing: a control whose counts are missing cannot be read. Both came from the same sentence on this page, which states the rule that nothing enforced — and the five shipped command lines now run plain -ra, with the rule pinned by tests/test_doc_links.py |
| running the dependency matrix | tests/test_snapshot_restore.py looked the two docket restore record documents up by filename, expecting the second to carry the -2 collision suffix. In the pytest + PyYAML cell the two commands stamped their records one second apart (…T081327Z and …T081328Z), so there was no collision, no suffix, and a StopIteration on a machine merely slower than the one the test was written on — the matrix's only failure in that cell, and the reason the test now identifies its records by what they say. |
Mutation testing¶
tools/mutate.py changes one site per run and asks whether the suite notices. A site is a
comparison operator, a boolean operator, a not, a boolean or small-integer constant, a
min/max call, or any return, rewritten through ast and spliced back by character span. A
mutant that the suite still passes is survived; a mutant the suite catches is killed. Timeouts
are counted and reported separately, never as kills.
$ python3 tools/mutate.py --file src/docket/verify.py --seed 42 --max-mutants 200 \
--json /tmp/verify.json --markdown /tmp/verify.md
Properties of the harness itself, because a mutation score is only as good as its tool:
- It falsifies itself first. Splicing every site back verbatim must be a byte-for-byte
no-op (
splice_only), and the unmutated sandbox must pass the whole subset before any mutant runs. If either check fails, no score is printed. The report also carries that control run's own counts — passed, failed, skipped — because passing and running are different facts: a skipped test cannot fail, so a mutant in the region a skip covers is scored as a survivor. The 2026-10-16 verify sweep is why the counts are there: run under an interpreter without the optionalcryptographyextra it skipped the signature tests, and the five mutants inverify.py's signature block survived a sweep with nothing left to catch them — 171/177 on the same tree that scores 176/177 with the extra installed. The harness cannot tell a deliberateskipiffrom a missing dependency; it can refuse to let a reader guess which environment a score was taken in. - It mutates a copy, one mutation at a time, restoring the file between runs.
- The subset is declared, not implied.
DEFAULT_TESTSnames the test files that make claims about each module. A survivor from a subset that does not cover the module's branches is an artefact of the subset, not a gap: verify.py's first wide run reported 23 survivors, four of them in the signing branch, and aftertest_signing_and_attach.pywas added to the subset and no source was changed it reported 2. - Duplicate keys are not identities. Two mutants at one line with the same operator (two
False -> Trueon a single line) are separate mutants: report and compare them as such. - The mutant must actually be the code that runs. A mutation whose replacement has the same
file size as the original —
0→1,max→min— and lands within the same clock second can be masked by a stale__pycache__entry, because CPython's cache key is (mtime seconds, size). The harness already runs its subset withPYTHONDONTWRITEBYTECODE=1and-p no:cacheproviderand copies the sandbox without__pycache__, so a reported survivor is a real survivor; anyone reproducing a survivor by hand has to clearsrc/docket/__pycache__between edits or they will be measuring the previous program. - The harness has its own tests, and one of them found a bug that had been inflating the
documentation's numbers.
tests/test_mutate.py(40) exercises the tool the way the tool exercises the source: a fixture with one of every site kind, the splice checked for being a byte-for-byte no-op, every mutant checked for changing exactly one line and still compiling, the six verdicts_run_testscan return pinned against real pytest runs, and the sampling checked for being a function of the seed. The first version of that file asserted a mutant on a line containing an em dash and got none. The cause was_span:astreportscol_offsetas a UTF-8 byte offset while the splice indexes characters, so on any line with a non-ASCII character before the token the splice landed inside a character, produced text that did not compile, and the site was dropped by a barecontinue.splice_onlycould not catch it — splicing the original text back is a no-op at any offset — and nothing counted the drops, so the effect was invisible:verify.pyoffered 95 candidate mutants instead of 177 (ledger.py171 instead of 172; every other mutated file was unaffected, because their sites sit on ASCII lines). The fix converts both offsets through_char_offset, and the regression is pinned by a fixture line with an em dash in it. Every score in the table below was re-measured afterwards; the reports written before the fix are kept beside the later ones (in themutation/directory of the workspace that produced them, which is not part of this repository — a report is an observation of the tree it ran against, so it is filed rather than overwritten). - A site that produces no mutant is counted, not dropped silently.
mutants_for_sourcenow records one{"file", "line", "operator", "reason"}per dropped site, the sweep prints the count, and the JSON report and the markdown header carry it. The four reasons are named — the span does not lie inside the file, the operator could not be applied, the replacement does not compile, the replacement is the original text — and two of them are driven by tests rather than waiting for the corpus to produce them. A report that says0 candidateswithout saying why is indistinguishable from a clean sweep of a file with nothing to mutate. - A single survivor can be re-checked with the harness's own machinery, in seconds.
--only-line 384runs every mutant on that line, sampling nothing, in the harness's own sandbox with its own interpreter and environment; the report records the selection so a filtered run is never read as a whole-file score, and a line with no mutant is refused rather than reported as a clean file. This exists because the honest next step after reading a survivor list is to reproduce one verdict, and reproducing it by hand is how a reader measures a stale__pycache__— or, as the em-dash bug shows, how a reader measures something the harness never mutated at all. - A paydown note is a claim, and it can be stale in three different ways. The tests written to
close a survivor name the mutant they were written for in their docstring, which is what makes a
later sweep readable against them — and a named mutant is a line-number index into a file that
moves, so the claim expires the moment either file does. Re-measuring
resilience.pyafter the module grew showed two names were false: one test claimed bothreturn Truemutants ofrecord_failurewhile only ever reaching the first, and another believed it killed the twoprobes_in_flight = 0assignments, which are unreachable behind the reset inallow. The 2026-10-16 sweep ofverify.pythen showed a third shape, and it is the one worth looking for first: the test did kill a mutant on its line and stopped one token short of the other. Four sites inverify.pywere that shape — an assertion that ended before the type suffix, a fixture where two percentiles were the same number, a boundary whose two spellings part company only at exactly three problems, and a sentence nobody had asked for — and each one is written out in The paydown below, with the receipt it reads. All four are repaired intests/test_cache.py, and the residue is one diagnosed equivalent (:186). Names are corrected here rather than deleted, because a docstring that says "kills X" is how the next reader decides whether a site is covered — and the sweep is what keeps the claim honest. - A survivor in a branch nothing can reach is not a gap. Two of this round's survivors were
mutants of dead defensive code — a second
torn_tail is Nonecheck under a guard that had already returned, and anevents is Nonecheck the reader cannot produce. They were deleted, not tested: branch coverage by assertion is theatre. The same reasoning removed arequiredparameter fromverify.section()(all three call sites passed it) and an existence fallback aroundp.stat()(the handle above it always creates the file).
The runs¶
Seed, candidate count and subset are recorded per run; scores are not comparable across seeds because the sample differs, and the candidate count changes with the source.
| module | candidates | run | killed | survived | timed out | score | wall clock | seed |
|---|---|---|---|---|---|---|---|---|
src/docket/verify.py |
95 | 95 | 94 | 1 | 0 | 98.9% (1) | 66 s | 42 |
src/docket/verify.py (after the tests below) |
177 | 177 | 176 | 1 | 0 | 99.4% (1) | 2,125 s | 0 |
src/docket/verify.py (after the §8 derived-member checks) |
237 | 237 | 229 | 8 | 0 | 96.6% | 2,988 s | 0 |
src/docket/verify.py (after the five tests the sweep above prompted) |
237 | 237 | 234 | 3 | 0 | 98.7% | 2,864 s | 0 |
src/docket/canon.py |
63 | 63 | 58 | 1 | 4 | 98.3% (2) | 209 s | 42 |
src/docket/ledger.py |
171 | 171 | 143 | 28 | 0 | 83.6% (1) | 276 s | 11 |
src/docket/ledger.py (after the tests below) |
172 | 172 | 144 | 28 | 0 | 83.7% (1) | 788 s | 0 |
src/docket/latency.py |
21 | 21 | 21 | 0 | 0 | 100% | 12 s | 0 |
src/docket/loaddriver.py (first) |
63 | 60 | 45 | 12 | 3 | 78.9% | 822 s | 0 |
src/docket/loaddriver.py (after the tests below) |
61 | 61 | 56 | 2 | 3 | 96.5% | 817 s | 0 |
src/docket/cache.py (first) |
126 | 60 | 49 | 11 | 0 | 81.7% | 378 s | 0 |
src/docket/cache.py (wide re-run) |
128 | 128 | 120 | 8 | 0 | 93.8% | 618 s | 0 |
src/docket/cache.py (after the tests below) |
128 | 128 | 128 | 0 | 0 | 100% | 432 s | 0 |
src/docket/resilience.py (first) |
166 | 60 | 46 | 14 | 0 | 76.7% | 454 s | 0 |
src/docket/resilience.py (wide re-run) |
166 | 166 | 141 | 25 | 0 | 84.9% | 877 s | 0 |
src/docket/resilience.py (after the tests below) |
166 | 166 | 163 | 3 | 0 | 98.2% | 228 s | 0 |
tools/helm_fidelity.py (first) |
47 | 47 | 13 | 34 | 0 | 27.7% | 28 s | 0 |
tools/helm_fidelity.py (after the tests below) |
47 | 47 | 45 | 2 | 0 | 95.7% | 41 s | 0 |
tools/mutate.py (after the tests below) |
144 | 144 | 144 | 0 | 0 | 100% | 342 s | 0 |
latency.py was written after the harness existed, and the harness earned its keep immediately:
its first run left one survivor — the return problems inside the "the receipt states a latency
summary but no usage row carries latency_ms" branch, replaced by return None. Nothing noticed,
because verify.py reads a falsy return as "no disagreement", so a receipt could state any
p99 as long as its rows carried no measurement: a false pass in the feature whose entire point is
that a stated measurement must be recomputable. The fix was a test for the branch, not a change to
the branch. The same module's first candidate list also contained an unkillable min(max(rank, 1),
len(ordered)) clamp — unreachable given percentile_ms's own guards — which was deleted rather
than explained, and the subset it needs is a reminder of the same lesson in the other direction:
run with only test_receipt_verify.py and the score reads 4.2%, because that file does not
touch the module at all.
Started from 67.5% (canon + verify, 80-mutant sample, seed 3) and 50.0% (ledger, 70 of 153
candidates, seed 11). The ledger number moved because the tests moved: the first run wrote no
test for the lock file's contents, the byte accounting, the ceiling fields, degraded(), the
repair path's sidecar naming, or the chain verdict's own silence.
The release round¶
One pass, one commit, one harness: the nine mutated modules of the release round, re-measured
together after the subsets and the source stopped moving, so that the numbers below are
comparable with each other and every one of them names the tree it was taken on. The tenth row
was added the same day, when tools/a11y_check.py — the release record's own accessibility
check — was given a subset and swept too; the section The tenth report below is that sweep. The parameters are the ones the
earlier rounds used where they are stated (--min-score 0, --seed 0), and the control
column is new: it is the unmutated sandbox's own summary line, before any mutant ran, in
pytest's words. An outcome that did not happen is not written (pytest names only what
happened), so 121 passed is 121 passed with nothing failed and nothing skipped — and the
report says so independently in its control block's clean flag. A subset that skips is a
subset that cannot kill, so a reader can now check the
environment a score was taken in instead of trusting the author's note about it. Every control
in this round skipped nothing, which is itself the evidence that the optional cryptography
and PyYAML extras were installed: the signature tests and the Helm-fidelity tests are
skipif-gated on exactly those. Every report names its own interpreter — here
3.13.16 — and its own subset, so
the environment a score was taken in is a field rather than a paragraph.
| module | candidates | run | killed | survived | timed out | score | wall clock | seed | control (as pytest wrote it) |
|---|---|---|---|---|---|---|---|---|---|
src/docket/verify.py |
177 | 177 | 176 | 1 | 0 | 99.4% | 2,127 s | 0 | 189 passed |
src/docket/ledger.py |
172 | 172 | 144 | 28 | 0 | 83.7% | 802 s | 0 | 121 passed |
src/docket/loaddriver.py |
61 | 61 | 56 | 2 | 3 | 96.5% | 815 s | 0 | 37 passed |
src/docket/canon.py |
63 | 63 | 58 | 1 | 4 | 98.3% | 515 s | 0 | 55 passed |
src/docket/cache.py |
128 | 128 | 128 | 0 | 0 | 100% | 437 s | 0 | 197 passed |
tools/mutate.py |
144 | 144 | 144 | 0 | 0 | 100% | 384 s | 0 | 40 passed |
src/docket/resilience.py |
166 | 166 | 163 | 3 | 0 | 98.2% | 250 s | 0 | 110 passed |
tools/helm_fidelity.py |
47 | 47 | 45 | 2 | 0 | 95.7% | 49 s | 0 | 17 passed |
src/docket/latency.py |
21 | 21 | 21 | 0 | 0 | 100% | 15 s | 0 | 117 passed |
tools/a11y_check.py |
136 | 136 | 134 | 2 | 0 | 98.5% | 89 s | 0 | 71 passed |
Every report carries its module's mutable sites, its subset, the control run's counts and the
verdict for every mutant it ran; provenance.py re-lists each file's sites and compares them
with the sites the report says it ran, so a report that describes a different file than the one
on disk is caught rather than quoted. The survivors this round leaves are classified under
Every survivor, classified.
The round reproduced every score the earlier rounds had reported, and — checked mutant by
mutant, as (line, operator, from, to) — every survivor and every timeout as well. That is the
useful part: the numbers quoted above were measured across three days, two environments and a
test suite that grew underneath them, and this round says which of them the current tree still
supports. It also says what the earlier rounds could not, because the evidence is in the report
rather than in a note about it: each subset ran, in full, against the interpreter named in its
own control block.
The control column found something on its first round. tools/helm_fidelity.py scored
45 of 47 with two survivors and a control run of 16 passed, 1 skipped, and the skip was the
test that runs the real Helm client — the comparison the module exists to make. The reason
was mechanical: the harness gives every test process a fixed PATH
(/usr/bin:/bin:/usr/local/bin) so that a score cannot depend on the operator's shell, and the
client was on a path outside it. With the client reachable the control run is 17 passed,
0 skipped and the score is the same 45 of 47 — an unchanged number is not the same fact as an
unchecked one, and a subtitle that says checked every YAML template has to be able to say
which client did the checking.
The paydown¶
The verifier's corpus is 177 candidates — the number the byte-offset fix revealed — and the first full round scored 126 of them, because the subset named three test files where the claims live in four. Naming the fourth moved the score without touching one line of the verifier: 148 of 177 (83.6%). The tests written for the survivors that round left moved it again, to 172 of 177 (97.2%) in a 2,109-second sweep at seed 0. Five survivors remained, which is few enough to read one at a time — and reading them was the point, because four of the five were tests that had killed one mutant on their line and stopped one token short of the other. Each stop was a different kind of near miss.
| site | what the test asserted | what the second mutant did behind it |
|---|---|---|
verify.py:865 |
… count: 'seven' — the value, and not the type suffix beside it |
type(malformed[1]).__name__: with two malformed rows of the same type the sentence reads identically either way, so the assertion could not see whose type was quoted |
verify.py:878 |
p50 12 ms · p99 12 ms over 1 measured call(s) |
the fixture measured one call, where p50 and p99 are the same number: looking up the wrong percentile printed the same sentence, character for character |
verify.py:1175 |
not [failure for failure in result.failures if "further origin problems" in failure] |
the stop condition counts a list, and its two spellings (> 3 and >= 3) print the same sentence for every length except one: at exactly three the mutant adds "further origin problems: 0 more" where the original says nothing. The test holds exactly three now — a group naming the wrong model, a token count that does not match the call it cites, and the appendix key that both takes the call out of the cached-response record and stops the tally containing it — and asserts the four sentences that follow, plus the absence of a fifth |
verify.py:1192 |
that a group whose hits is not a positive count is refused |
the guard refuses it, and the tally loop is a second pass over the same groups that does not skip them: with int(group.get("hits") or 0) mutated to or 1, the falsy count becomes the ledger's own count, the comparison agrees, and the second sentence about the same group is never written |
All four repairs are in tests/test_cache.py and marked in place: the assertion extended to include
(str); a second metered call with a different latency (extra_latency=40) so that the two
percentiles differ before one of them can be looked up wrongly; a third origin problem, so the list
holds exactly three — the one length at which the two spellings of the stop condition part company;
and an assertion for the tally sentence, which no test had ever asked for. None of the four needed a
change to src/docket/verify.py.
The sweep was then re-run over the file as it stands after those tests and after the derived-member
checks (§8 items 5a/5b), which took it from 189 mutable sites to 237: the first run of that
sweep left 229 killed, 8 survived, 0 timed out — 96.6%, 2,988 s at seed 0, control run 264 tests.
Eight survivors is a worse number than the last run's one, and the reason is not that the tests got
worse: 189 of those sites did not exist before, and a new check is written before the test that pins
every branch of it.
Each of the eight was then classified, because a survivor count is not a defect list. Five were gaps
in the new code and were closed with tests that name the behaviour — by_model members as integral
floats (§3.2 normalises them), the three usage-source counts omitted on a session with no metered
calls (absent is not zero), and a mixed basis over rows that are all one kind (a claim that must be
falsifiable). The fifth was the worst of them: not (reported and estimated) weakened to or makes
"mixed" unfalsifiable for exactly the case it exists for. Three are equivalents, demonstrated rather
than argued: the two bool/None fast paths produce the same status and the same sentence for
true and null (verify.py:194 in whole, verify.py:654 in the by_model normaliser), and the
breakdown_judged flag at verify.py:694 sits behind if recomputed is not None — a row whose cost
is not an amount fails cost-recomputation and the breakdown block is never entered, which is shown
by probing both shapes and observing no by-model-totals verdict at all.
The re-run, with those five tests in place, is the number this page quotes above:
234 killed, 3 survived, 0 timed out — 98.7%, 2,864 s at seed 0, control run 271 tests, and the
three survivors are the three equivalents. A score is a statement about a corpus of changes, not about
correctness — 98.7% means "of 237 single-site changes to this file, 234 were noticed by something".
The general lesson is narrower than "write more tests" and worth stating: a docstring that names a mutant is a claim about that mutant, a line can carry two of them, and an assertion that is right about the sentence it checks can still be blind to the half of the sentence it stopped before.
Every survivor, classified¶
A survivor is either a gap or an equivalent mutant. Each one is listed here with which it is and
why; an equivalent mutant is not a defect and no test pretends otherwise. The three verify.py
rows below are that file's entire survivor list in the run this page quotes (98.7%), and the five
survivors the same sweep found before those tests existed are listed in the next section with the
tests that closed them. Every other file's rows come from the run that produced them.
| site | mutant | verdict |
|---|---|---|
verify.py:194 |
isinstance(value, bool) or value is None → and |
equivalent. whole(True) fails as a bool on one path and as the unparsable string "True" on the other, and whole(None) likewise; both routes record the same named failure with the same text. |
verify.py:654 |
the same expression in quiet_whole, the by_model normaliser |
equivalent. Probed with by_model[0].calls set to true and to null: the mutant and the original both report by_model['claude-sonnet-5'].calls is not a whole number, character for character. |
verify.py:694 |
breakdown_judged = True → False in the arithmetic-failure branch |
unreachable. The branch that sets it is inside if recomputed is not None; every input that could raise there (a non-numeric token count, a cost that is not an amount) fails cost-recomputation first and returns recomputed = None, so the derived-member block never runs — probed with both shapes, and by-model-totals is absent from the report in each. |
canon.py:51 |
or → and in the early return for bool/None/str |
equivalent. The fallback at the end of _normalise returns those three types unchanged as well; the early return is a fast path, not a behaviour. |
canon.py:139, canon.py:160 |
len(level) > 1 → >= and 1 → 0 in the while loop of merkle_root and of merkle_proof |
non-terminating, all four. Each turns a one-node level into a level that is promoted to itself, so the tree never finishes. They exceed a 45 s per-mutant timeout (the same sites exceeded 120 s in an earlier run): reported as timeouts, never as kills, because a hang is not the suite noticing a wrong answer. |
ledger.py:153,160,161,168 |
compare=False → True on the clock, the two handles and the append lock |
equivalent. Nothing compares two Ledger objects — the field option is declared so that a repr and a comparison cannot reach into a descriptor, and the repr half of the same declaration is killed by test_the_repr_of_a_ledger_is_safe_to_put_in_a_log. |
ledger.py:168 |
init=False → True on the append lock |
equivalent. A lock passed to the constructor changes nothing; nothing constructs a ledger that way. |
ledger.py:215 |
if size > 0 → >= |
equivalent. For a zero-byte file the guarded block reads the file and extends the event list with nothing. |
ledger.py:277 |
getattr(self._handle, "closed", False) → default True |
equivalent. The attribute exists on every file object a ledger can hold; the default is reachable only for an object that is not a file. |
ledger.py:301 |
self.max_bytes or 0 → or 1 |
equivalent (unreachable). This expression is inside LedgerFullError, raised only when a ceiling is set, and _room_for never refuses when both ceilings are None. |
ledger.py:369 |
__exit__ returning False → None |
equivalent. The with statement tests truthiness; both let an exception through. |
ledger.py:410 |
the diagnostics skeleton's trailing_newline: True → False |
equivalent. Every path that reads bytes overwrites it; it is readable only for a file that does not exist, where neither value is a claim about bytes. |
ledger.py:632-646 |
six mutants: the and/not of the stale-lock test (:628), unlink(missing_ok=True) in the stale branch (:629) and in the release path (:646), and the two returns of the stale branch (:630, :633) |
five unreachable here, one equivalent. :628–:633 sit inside _acquire_lock's try: # pragma: no cover - Windows block, past the fcntl early return, so on any platform with flock they cannot run at all — the POSIX equivalents are killed. :646 does run here, and its missing_ok=False is equivalent because a lock file that is already gone raises FileNotFoundError, an OSError the line above suppresses. |
loaddriver.py:216 |
if next_call > now → >= |
equivalent. At next_call == now the mutant calls time.sleep(0.0) where the original skips the call; a zero-length sleep does not move the schedule. |
loaddriver.py:265 |
while time.monotonic() < due → <= |
equivalent in effect, and stated precisely. The outer loop samples RSS on a fixed cadence; this inner loop only waits until the next sample is due, sleeping in 20 ms steps. The mutant takes one extra step when the clock reads exactly due — a nanosecond-level coincidence — and the only consequence is that the next sample can be up to 20 ms later than it would have been. No sample is added or lost, and the sampling quantum is 20 ms, so the series a soak reports is the same series. |
loaddriver.py:130,132,133 |
return False → True on the call budget and on the deadline, and issued += 1 → += 0 |
non-terminating, all three. Remove the guard that stops a run and the run does not stop, so the test that drives it never returns within the 45 s per-mutant timeout. Reported as timeouts, never as kills — same family as the merkle_root mutants above. |
ledger.py:676-672 |
ten mutants in the Windows process-liveness probe _pid_alive: the two WinDLL/OpenProcess arguments (:664, :665), the two if not … guards (:666, :670), what each guard returns in both spellings (:667, :671 — the value swapped, the return replaced by None), return code.value == still_active replaced by None (:672), and that comparison's ==→!= (:672) |
unreachable here. The whole block is under if os.name == "nt", which asks the process table directly instead of calling os.kill — on Windows that is TerminateProcess, so the liveness probe would kill the process holding the lock. The Windows bullet under Not covered, and why says what that costs. |
resilience.py:76,151,161 |
probes_in_flight = 0 → 1 in the dataclass default, in record_success and in record_failure |
equivalent, and the file's own test says why. allow sets probes_in_flight = 0 on the transition from open to half-open — that is the site the test kills — so the value these three sites leave behind is overwritten before anything reads it. What the test pins instead is the behaviour a reader cares about: an upstream that recovered is admitted again on the next outage, not only the first. |
helm_fidelity.py:53 |
if spec is None or spec.loader is None → and |
unreachable, and marked as such. The guard carries # pragma: no cover; spec is None only when spec_from_file_location fails, and for a file that exists the loader it returns is never None. Reaching the branch needs an interpreter whose import machinery returns a loaderless spec. |
helm_fidelity.py:105 |
zip(left, right, strict=True) → strict=False |
equivalent. The line above returns a length mismatch before the zip is built, so the two lists are always the same length by then and strict can never fire. A belt kept on after the braces. |
ledger.py:690 |
except (ValueError, OverflowError) → the two mutants of its return False |
unreachable on this kernel. os.kill reports a pid no kernel would hand out as ESRCH, which is an OSError and is handled (and killed) by the branch above; this guard is for the platforms that raise OverflowError instead. |
a11y_check.py:289 |
return [] → return None, the fallback of _native_text_alternative |
equivalent. Its only caller is if native: — None and [] are both falsy and the caller never iterates the result, so no page can distinguish them. Deleting the fallback would not help: an early return None on every path is the same mutant wearing a different line number |
a11y_check.py:132 |
the parser's sentinel node Node("root", {}, 1) → line 0 |
equivalent. The sentinel is not an element any rule reports — every rule walks root.descendants(), which starts at the children — so its own line number is read by nothing. It is a marker for the stack's floor, and Node.line is a field the sentinel has to fill |
The tenth report: the checker's own sweep¶
tools/a11y_check.py reads the published site and is the release record's sixteenth check, so it was
swept on 2026-10-16 like the machine it runs in: 136 candidates, 134 killed, 98.5%, no timeouts, a
subset of its own tests, a control run of 71 passed, filed as mut-a11y-release.json — and every
number in this section can be read from that file. It was the third sweep on this file: the first
scored 67.1% and the second 91.9%, and neither of those two reports exists any more, because
each sweep writes to the same path and the next one replaced it. The 47 survivors they left are what the
repairs below were read from; the paragraph is their record now, and the score that survives with its
evidence beside it is the 98.5%.
| the sweep found | what was wrong |
|---|---|
| no fixture used a self-closing tag | <img … /> goes through a different parser handler than <img …>, and both handlers' mutants lived: a self-closing image kept its attributes (or lost them) with nothing to notice |
| nothing asserted a line on a page with no landmarks | mains[0].line if mains else 1 and its two siblings could return the second element's line, or 0, and every test still passed |
| the name precedence was read from the verdict | a link with text and a tooltip passes whether it is named by the text or by the tooltip, so three mutants in the tally and the precedence lived for a whole round |
| one fixture had one control per name source | the counter's lookup key could become the second character of the source name and the tally would still be right on every page in the file |
| the parser's end-tag search floor | dropping the floor from range(len(self._open) - 1, 0, -1) left an outermost element open and nested everything after it inside — the rules still ran, on the wrong tree |
--build was tested with a site that did not exist |
a mutant comparing the build's exit code to 1 exits 2 for the missing site's reason, so the test passed for the wrong cause; and nothing ran a successful build to kill the inverted comparison |
Three findings were defects in the tool rather than holes in its tests, and all three were deleted-wrong
code rather than missing checks. The document title was captured by a _capturing_title flag whose two
branches the sweep proved unreachable — handle_data keeps only the first captured text, and a page's
<title> always precedes its other text — so the title is read from the finished tree now. A form control
was named by its own content, which WAI-ARIA forbids (a control's name sources are the author's): a
wrapped <textarea> was being named by the text inside it. And a wrapping <label> fell back to the whole
wrapper's text when it had none of its own, which names a control after its own value.
The harness found the fourth defect itself, in a test written minutes earlier: the new
test_the_default_site_is_the_one_this_repository_builds asserted ROOT.name == "docket" — true of this
workspace, false of every clone made into a differently-named directory. The sweep refused to run:
sandbox does not pass the suite before any mutation (1 failed, 64 passed). A harness that scores mutants
against a baseline that does not pass is measuring nothing, so the refusal is the feature, and the test now
asserts where it is in the tree instead of what the tree is called.
What the round changed¶
Writing the tests that kill a mutant is where the defects were:
| found by | defect |
|---|---|
a test for a surviving return |
quarantine_torn_tail reported performed: true having moved zero bytes when the interrupted write had landed its partial line with a newline: the split took everything after the last newline, which is empty, and the ledger was left exactly as damaged as it was found — an operator's repair that repairs nothing, and says it worked. It now splits by the reader's own measurement of the torn tail. |
| reading a survivor in a Windows branch | _pid_alive probed with os.kill(pid, 0). On Windows Python's os.kill is TerminateProcess, so the liveness check for a stale lock would have killed the process holding the lock — the opposite of the check, with a blast radius that reaches another run. Windows now asks the process table (OpenProcess/GetExitCodeProcess) and answers "alive" when it cannot tell. |
| a test for a stated-but-unmeasurable latency | check_summary's "no usage row carries latency_ms" branch returned a list nobody had ever asserted on; mutating it to return None changed no test, and the verifier reads a falsy return as agreement, so a fabricated summary verified. (Found by mutating the new module, not by reading it.) |
| a test for the diagnostics' shape | read_with_diagnostics promised "the same keys on every path" in a comment while the missing-file path returned nine of eleven: healthy and chain_error raised KeyError for a caller that had only ever seen a healthy ledger. |
| a test for the ceiling's error | LedgerFullError's byte ceiling collapsed to 0 under a mutated or, and the reported usage shifted under a mutated index, and nothing read either field. |
| reading a ledger's repr | the repr carried a descriptor, a lock handle and a memory address into CI logs. |
a test for a surviving max → min |
the existing rate test passed with the mutant applied: it measured total wall clock through a cold boundary, where the first call's own cost cleared the 0.4 s threshold, so the soak's central claim ("the load is paced") was pinned by nothing. It now warms up first, asserts the schedule's floor, and compares against an unpaced run. |
| a surviving sentinel comparison | note_rss compared a bare -1 against a bare 0 in the same expression, and both mutants survived. The answer is now a named constant (UNMEASURABLE) with a test that feeds the sampler the sentinel and a real reading. |
| mutating the harness and asking it a question its own call site cannot | _is_docstring's four guards and its two returns — six mutants — survived because the one place that calls the function has already established what they check: it is reached only from the --include-strings branch, where the node is a non-empty string constant. Raising the guard's lower bound from byte zero to byte one was worse than unkillable-and-harmless: a site at the first byte of a file was silently dropped, and a drop is reported as "a site that produced no mutant", never as a survivor. Both are now asked directly. |
treating rmtree(ignore_errors=True) as strict |
four mutants, all in the sweep's cleanup. The cleanup runs in finally:, so a PermissionError there would replace whatever the run was about to report — the report, a broken baseline's refusal, a refused splice, or a refused line filter — with a traceback about a temporary directory. A test under mutation can leave exactly that shape behind (a read-only directory is a normal thing for a test to create), so a fixture tree now contains one, and four tests assert that each exit still reaches the reader. |
| a survivor in a liveness probe | os.kill(pid, 0) → os.kill(pid, 1): the probe answers the same question and sends SIGHUP while asking it — the same defect class as the Windows os.kill, which is TerminateProcess there. The existing test could not see it, and the reason is a property of the measuring environment: every sweep runs under nohup, which ignores SIGHUP and passes that disposition to its children, so the signal is a no-op where the score is measured. The new test makes delivery observable instead of fatal — the child installs a handler and creates a marker file inside it. |
reading 51 survivors of verify.py |
they were not gaps in the verifier and not equivalent mutants: DEFAULT_TESTS gave the file three test paths, and the block that recomputes a stated cache saving (cache-savings, cache-hit-origins, cache-hit-proofs) is exercised from tests/test_cache.py, which the list did not name. A subset is part of the measurement: the same 177 candidates scored 71.2% against three files, 83.6% once the fourth was named, and 97.2% after the tests those survivors asked for — and the five left after that are the round recorded in The paydown above, four of them tests that stopped one token short. The list now names tests/test_cache.py, first, with the reason beside it. |
Five of the eight survivors the 237-site sweep first reported were gaps in code that had been
written hours earlier, and each is closed by a test that names the behaviour: an integral float in
by_model (§3.2 normalises it — verify.py:657), the three usage-source counts omitted on a
session with no metered calls, where -1 is the only thing separating "absent" from "stated as
zero" (verify.py:766–:768), and a mixed basis over rows that are all one kind, weakened to
or so that the claim is unfalsifiable in exactly the case the sentence exists for
(verify.py:808). The worst of them is the last: it is not a crash and not a wrong sentence, but a
pass where the receipt's own classification is contradicted by its rows. They were found by
mutation after the tests were green, which is the only reason they were found at all.
Not claimed¶
A mutation score is a statement about the mutants a tool generated, over one subset, at one
commit. It says nothing about the defects no mutant represents: a missing feature, a wrong
decision, an interface nobody needed. 83.7% on ledger.py with every survivor explained is not
"the ledger is 83.7% correct"; it is "of 172 single-site changes to this file that this harness
produced, 144 were caught by these tests, 28 are provably equivalent or unreachable here, and
none is unaccounted for". The same sentence applies to the verifier with its own numbers:
98.7% is not "the verifier is 98.7% correct"; it is "of 237 single-site changes to
verify.py that this harness produced, 234 were caught by these tests and 3 are
argued equivalent, with the argument written out in the table above".
Not covered, and why¶
- A full mutation score over the whole source tree.
tools/mutate.pyhas been run over nine files — the two whose promises are cryptographic (canon.py,verify.py), the one that holds the evidence (ledger.py), the measurement added on top of them (latency.py), the load driver (loaddriver.py), the response cache, the resilience primitives, and two of the tools themselves (helm_fidelity.pyandmutate.py, the harness scoring its own tests).deploy_check.py,loadtest.pyandfuzz.pyare mapped inDEFAULT_TESTS— each with the test file that would kill its mutants — but not yet measured, and a mapping is not a score. The rest ofsrc/docketis not mutated: a score forproxy.pyorcli.pywould be a much larger run, and a number nobody has run is not a number. The harness's per-module test subsets name the files each module's promises are made in, including the invariant suite for the lifecycle modules, so a future run ofsupervisor.pyorscriptagent.pystarts from a subset that can actually fail. What has been run, and what every survivor turned out to be, is in Mutation testing below. A tool is in that list only when its own tests exist: a harness with no tests is a harness whose numbers nobody can check. - Endurance beyond ten minutes, and at scale.
make soakruns a real boundary for ten minutes at a paced 20 calls/s and shows memory tracking events rather than time (≈1.1 KiB per event, both at 6,002 and at 12,002 calls). That is a bounded soak on one 2-vCPU host: it cannot speak to hours, to a slower filesystem, or to the ten-million-event ledger whose in-memory cost the same arithmetic predicts (~11 GB, againstmax_ledger_bytesas the ceiling that stops it). - Throughput as a curve. There is one measured point, not a curve: 8 threads unpaced on 2
vCPUs, where the append lock and its
fsyncare the bottleneck. No requests-per-second target is claimed for a production agent, no tokens/s number exists, and no concurrency sweep has been run — a single point is what the reports contain and all they are used for. - Live providers. CI never calls Anthropic, OpenAI or Google. The wire format is exercised
against a scripted upstream; a provider-side change to a router, a header or a usage field
would be caught by a user, not by CI.
--onlineexists for that, and is deliberately not automated. - A local attacker racing Docket. The confinement tests cover a hostile document. A process that can race the restore between its check and its write, on a tree the operator has already handed to an autonomous agent, is out of scope and named as such in the threat model.
- Accessibility beyond the terminal. There is no GUI to test. The
NO_COLORpath, the monochrome golden files and the markdown report are what "nothing is only conveyed by colour" means here. The documentation site is the one HTML artefact andtools/a11y_check.pydecides ten mechanical questions about it; it is a static check, not a substitute for a person using a screen reader on a real page, and WCAG 2.2 conformity is not claimed here. Its name computation implements the precedence of AccName 1.2 §4.3.2 and not the whole algorithm: no CSS generated content, no shadow roots or slots, noaria-hiddenexclusion inside a name, no full flat-string normalisation, and no role-based prohibition of naming (safe here because only links and form controls are asked, and both allow naming). It also has no equivalent of a contrast check, a focus-order check or a keyboard-traversal check — the four things a person with a screen reader would notice first if they were wrong. - A suite that has been proved to hold under heavy CPU contention. Under artificial load —
two busy loops on a two-core box, a third process's worth of oversubscription —
test_chaos.py's rollback drill failed once: it drives a whole game-day phase as a subprocess with a wall-clock budget, and it prints the phase's record when it fails, which is how the next occurrence would name the field that moved. It did not reproduce in three attempts under the same load, and the unloaded suite is green on every run recorded here, so this is written down as an observed sensitivity rather than a diagnosis. The pacing test was rewritten for the same reason after a run at load showed its margin was 5%: it now asserts the schedule's floor, which contention can only move in the safe direction. - Windows. There is no Windows in this environment or in CI, so nothing Windows-specific is
claimed: the
O_EXCLlock fallback, theOpenProcessliveness probe and the process-group kill are written, are the reason those code paths are marked# pragma: no cover, and have never been executed here. The lock fallback's liveness probe killed a live process until this round (see Mutation testing); a Windows contributor's first run is the one that will exercise it.
-
Measured before the
_spanbyte-offset fix intools/mutate.py(see Mutation testing): the candidate count forverify.pyandledger.pyhere is lower than the number of mutable sites those files actually hold (95 of 177, and 171 of 172), so these scores describe a smaller corpus than the rows below them. The numbers are kept as they ran. ↩↩↩↩ -
All four mutants in this family failed to terminate within the 45 s per-mutant timeout. The score is over the 59 mutants that produced a verdict (63 candidates minus the four timeouts); a timeout is reported beside the score, never folded into it. ↩