Retrospectives¶
ℹ️ Version numbers below reflect the convention in use when this was written. Unbuilt work is now named, not numbered (STATUS.md). This record is left as it was.
One section per increment of the project's build plans (plans/), written during that increment's
retrospective review (the workflow is in CLAUDE.md). Only findings worth keeping
land here: a real defect the review caught, or a fact that would be expensive to rediscover. Fixes
themselves live in the commits; this file records what was learned.
Every heading and claim here carries YYYYMMDD HH:MM (local, 24h) — several increments can
land in one day, and a bare date loses their order.
Severity follows the design review's scale: HIGH — wrong behaviour or false confidence; MEDIUM — would block or mislead; LOW — worth remembering, not urgent.
The seven pre-implementation design review passes are at the foot of this file: Design review passes 1–7.
Start here — by what you are about to touch¶
Added 20260801 01:11. Forty-odd sections in date order is an archive, not something anyone reads
before starting work — so this table is the way in. It is keyed on what you are about to do, not
on when the lesson was learned, and it is deliberately short: only the classes that have recurred.
A rule that hardened into a standing instruction lives in
CLAUDE.md or BUILDING.md;
this file is where the evidence for it is.
| About to… | Read | Because |
|---|---|---|
| write a test | I2, L3–L4, L5, L5b | The recurring defect of this whole project: an assertion satisfied by something other than the property it names. A test that could not fail; a field with no assertion is a field that can be a constant; a tidy fixture defeats a mutation test; test the discriminating case, not the two sides separately; a fixture can be right for the wrong reason and hide the defect it was written for |
| claim a test is mutation-verified | L5b, L3–L4 | "Mutation-verified" is a per-assertion claim, never a per-commit one. A failing test proves the mutant is caught, never that it is caught for the stated reason — under -x, or when the failure lands on an earlier assertion, the one encoding the claim never runs |
| touch a sidecar or any YAML | I5, L5b | Writes must be rename-atomic. An explicit empty value was silently deleted on round-trip. Swapping a YAML library is not a swap; existing[:] = keep wipes ruamel's comment metadata outright; a comment before a sequence entry belongs to the entry above it; a warning is not an error, and a library that downgrades one is changing behaviour |
| add a gate or a check | I7a, L3–L4, the eval-harness section | A gate that has never been shown to fail is a claim, not a check. A gate that never reads the artifact it guards is checking a copy. The free-path gate was defeated by its own harness. The exit criterion was the thing nobody ran |
| write an error message | L3–L4, I8 | An error message is part of the interface; a remedy inside one is a claim, and it was false. A fix applied to one surface is half a fix |
| change retrieval | I9, the eval-harness section, I6 | Three defects under one green suite. Overlap could push a chunk past max_tokens; heading text landed in no chunk at all |
| edit a doc, a plan or an exclusion list | L5b, I9, the shared-file section | An exclusion list is a set of claims, and claims rot. A fix instruction can carry its own defects. Four silent str.replace no-ops in one session — an edit that does not match is an edit that did not happen. A clean auto-merge is not a correct merge |
| cut a release | the post-v0.1 housekeeping section, the two release-sweep sections (20260811) | A CHANGELOG entry and a __version__ are only claims: 0.1.0 had both for two days with no tag, no release and nothing published. Verify with git tag -l, gh release list, and the index itself. A release sweep is table-shaped and misses prose — grep for the superseded version number — and ordering is a property of the sequence: read the sequence, not the neighbourhood |
| trust CI | the cross-platform scanned-fixture section | main was CI-red for three pushes and nobody noticed. Green proves the tests ran, never that they can detect the defect |
| run a mutation pass | G1, L6 § Mutation testing, T3, T7, E5 | Commit before mutating — git checkout restores to the last commit and has silently reverted uncommitted fixes six times. A harness must prove it can kill before its silence means anything: assert the anchor matched exactly once, clear __pycache__ between mutants, run without -x, restore from a copy. A killed run poisons everything after it, and a surviving mutant first asks is this reachable at all? |
| write a measurement tool, or publish a number | the reachable-ceiling probe, measure_sync_cpu.py, E2, E6 |
A tool that absorbs input it cannot measure reports a plausible number — strictly worse than a crash. Validate everything the measurement consumes, not the bugs already reported; the artifact names every input its numbers are a function of; one test runs the tool the way it is really invoked, because the shipped one measured the launcher and not the work. A probe is code and earns the same adversarial pass — two of five constants were measured wrong on E6's first run |
| lean on a fake, a seam or a fixture | E6, I7b (passes 6–7), the [light] backend error, Running it found what reading it could not |
A seam built for testability defines a region no test reaches, and that region needs its own gate: the deep client's schemas 400d on every live call for four releases of green fixtures. "Each part works" is not "the parts are connected". For any new knob, turn it on a real KB before landing — only running proves the parameter arrives |
| apply a review fix | T3, T4, L6, 2d round two | A fix applied under review inherits the review's confidence and none of its scrutiny — every review loop here has found its worst defects inside the previous pass's fixes. "Pinned by test X" is a claim about a failing test: revert the fix and watch it go red, or do not write the word. Re-run the whole battery after every fix — a fix can silently disarm a test written for something else |
The pattern across all of them, and the reason this file is worth keeping: the worst finding of a review pass is usually inside the previous pass's fix. That has held from I5 through L6's thirteen rounds, and it is why a fix is re-reviewed rather than assumed.
I1 — Package skeleton, errors, CLI dispatch (20260725 13:40)¶
MEDIUM — PinakesError could not be pickled, so an error crossing a process boundary raised
TypeError instead of reporting itself. Exception.__reduce__ replays self.args through
type(self), but every subclass here takes its own constructor arguments (NotImplementedYetError
takes a command name and an increment), so rebuilding blew up on the missing remedy keyword.
Confirmed by probe before fixing, not reasoned about. Fixed with an explicit __reduce__ routing
through a module-level helper. Lesson: an exception class with a non-(message,) constructor is
unpicklable by default — the failure only surfaces under xdist/multiprocessing, i.e. exactly when
something else has already gone wrong.
MEDIUM — the subcommand dispatch target sat on the public namespace attribute run. Any future
command declaring --run would have silently overwritten the function main() then calls. Moved to
a reserved _runner dest with a test asserting no public namespace attribute is ever callable.
Lesson: set_defaults shares one namespace with every option; anything the framework itself
dispatches on must be underscore-reserved.
LOW (reference) — measured ty 0.0.63 against pyright strict on a 6-defect probe. pyright
caught 6/6; ty caught 1/6 (the str | None → len error, with better diagnostics); ruff caught the
unused import. ty currently has no strict mode: it accepts unannotated defs and Any leakage, which
is precisely what pyright strict is in this project for. Decision (user): keep pyright as the gate,
add uv run ty check as a fast pre-check. Worth re-measuring when ty leaves beta — the gap is a
missing feature, not a design difference.
I2 — ULID identity and pnk:// URIs (20260725 14:05)¶
MEDIUM — a test that could not fail. test_an_unresolved_uri_cannot_be_formatted asserted
not hasattr(parsed, "__str__") or "pnk://" not in str(parsed). Every object has __str__, so the
first clause is always false and the second only checked a dataclass repr — the test would have
passed even if ParsedUri had grown a full URI renderer. Replaced with a precise structural
assertion ("__str__" not in ParsedUri.__dict__, present on PnkUri) that names the static
guarantee as primary. Lesson: a green test asserting a tautology is worse than no test — it buys
false confidence, and hasattr(x, "__dunder__") is almost always one.
MEDIUM — a docstring claimed more than had been verified. ids.py said python-ulid rejects the
ambiguous Crockford letters I, L, O, U; only I and U had actually been probed. All four
are now probed and the claim is stamped with the time of that probe. Lesson: when writing "verified
X", the set being claimed must be the set that was run — a partially-probed claim reads identically
to a fully-probed one.
MEDIUM — two except Exception blocks wrapped calls whose only expected failure was
InvalidIdError, so a TypeError from a future refactor would have been re-raised as "this is not
a valid KB ULID". Narrowed, and ruff's BLE ruleset enabled so the class of mistake cannot be
written again.
LOW — internal helpers were public. parse_kb_id_for_uri/parse_doc_id_for_uri took an odd
(raw, segment) pair and had no business in the module's API; renamed to _kb_segment/_doc_segment.
LOW — the scheme is matched case-sensitively while the self sentinel is not. Deliberate (URIs
are machine-written, self is hand-typed) but undocumented; now stated in the module docstring and
covered by a test.
I3 — Manifest parsing and KB root discovery (20260725 14:25)¶
MEDIUM — an explicit empty value silently became the default. timezone = "" in [budget] read
back as "UTC", because the accessor ended in or "UTC". Same shape accepted name = "" and
model = "" outright. All three confirmed by probe. Empty strings are now rejected with a named
key, and the default only applies when the key is absent. Lesson: value or default conflates
"missing" with "empty", and for user configuration those mean opposite things — one is silence, the
other is a mistake worth reporting.
MEDIUM — narrowing by assert. Two call sites used assert value is not None to convince the
type checker, and a _require fallback built a Path out of a table name for an error that could
never fire. Python strips asserts under -O, so the "guarantee" was a comment with syntax. Replaced
with three explicit accessors — string (required, returns str), optional_string, string_or —
which give the type checker what it needs without a runtime claim. Lesson: when a type checker
needs an assertion, the API shape is usually wrong; fixing the signature beats asserting.
MEDIUM (docs) — the required/optional split existed only in code. [chunking], [retrieval],
[rerank] and [budget] are optional with documented defaults while [kb], [sources] and
[embedding] are mandatory — a user-facing contract that docs/DESIGN.md §2.1 never stated. Added
there in the same change, per the repo's docs rule.
LOW — a test caught a real error-message defect. Validation errors read
[<root>.retrieval]; the table path is meant to name what the user would type. The failing test was
fixed in the source, not the assertion.
I4 — SQLite storage, FTS5 triggers, vector loading (20260725 14:45)¶
MEDIUM — load_vectors peaked at roughly twice the array it returned. It collected every
embedding into a list and vstacked it: measured 669 MB for 200k×384, where the result is 307 MB.
At 1M chunks that is ~3.4 GB against the ~1.5 GB §3.1 states, so the design's own memory claim was
wrong for its only shipping tier. Now counts first and fills a preallocated array, with a test
asserting peak < 1.6× the result. Lesson: "load it all into one contiguous array" has two
implementations that differ by a factor of two, and only one of them matches what the design
promises.
MEDIUM — a non-database file produced a raw sqlite3.DatabaseError. PRAGMA journal_mode is
the first statement to touch the file, so it failed before any schema check could run, and the user
got file is not a database with no remedy. Opening is now wrapped. Lesson: the friendly check
ran second; the pragma that configures the connection is what actually opens the file.
MEDIUM — chunk insertion restarted ordinals at 0 on every call, so a second call for the same
document hit the UNIQUE (doc_id, ordinal) constraint. The operation is really a wholesale replace
— re-chunking must not leave old chunks searchable — so it now deletes first and is named for that.
Both this and the one above were caught by tests written in the same increment.
MEDIUM — pickling collapsed every error subclass to PinakesError. I1's __reduce__ fix
rebuilt through the base class, so StoreError came back as PinakesError and any except
StoreError on the far side would miss it. Now rebuilds the original class via __new__, keeping
message and remedy. Lesson: a fix that makes an object survive a round trip is not the same as one
that makes it survive intact — check identity, not just contents.
LOW — DOCUMENT_STATES/LINK_ORIGINS duplicated the DDL's CHECK constraints with nothing
tying them together. A test now fails if they drift.
I5 — Sidecars (20260725 14:55)¶
HIGH — sidecar writes were not atomic. path.write_text truncates before it writes, so a crash
or full disk mid-write leaves a truncated sidecar — and the one thing a sidecar carries that cannot
be recomputed is the document's permanent ULID. Losing it breaks every inbound pnk:// link, and no
later command can repair it, because nothing else knows what the id was. Now writes to a sibling
temporary and os.replaces over the target. Lesson: "the truth is in files" makes every file write
a durability question; the ones holding unrecoverable identity deserve rename-based atomicity.
MEDIUM — an explicit empty value was silently deleted on round-trip. tags: [] and
provenance: {} vanished, because write tested truthiness. This is exactly the lesson I3 recorded
one increment earlier — absent and empty are different statements — and I repeated it in a module
whose entire contract is "do not lose what the user wrote". The Sidecar now records which known
keys the file carried. Lesson: a recorded lesson only helps if it is re-read while writing the next
thing that could break the same way; the pattern to watch for is any if value: guarding output.
MEDIUM — a hedged test assertion. assert made.title == "my research notes.md" or made.title ==
"my research notes" accepted either answer because I had not checked which one Path.stem gives.
Two plausible values means the test asserts nothing about the one that is correct. Pinned to the
real value. Lesson: an or in an equality assertion is the tautology smell from I2 wearing a
different hat.
LOW — KNOWN_KEYS could drift from what write emits, which would be silent data loss for a
key the module claims to understand. A round-trip test now asserts every known key comes back.
I6 — Structural chunking (20260725 15:10)¶
HIGH — overlap could push a chunk past max_tokens. The carried-over tail was prepended
unconditionally, so overlap = 9 with max_tokens = 10 produced 12-token chunks (probed). Those
are exactly the chunks the model truncates at encode time, silently — the failure §4.6 exists to
prevent, reintroduced by the feature meant to improve context. The carry is now dropped when it
would breach the limit, and a parametrised test sweeps the whole (max_tokens, overlap) matrix.
Lesson: the earlier test used overlap=5, max_tokens=15 and passed; one comfortable ratio proves
nothing about the boundary. Sweep the matrix when two parameters interact.
HIGH (design, found by a test) — heading text landed in no chunk at all. Headings were consumed
as pure structure, so a word appearing only in a heading was unsearchable: the FTS index sees chunk
text and nothing else. heading_path looked like it covered this, but it is a separate column that
v0.1 never searches. Headings are now part of the first chunk beneath them, and docs/DESIGN.md
§4.6 says so. Lesson: "the information is still recorded somewhere" is not the same as "the
information is still retrievable"; check which column the query path actually reads.
MEDIUM — sentence splitting silently gave up on text without punctuation. A long run with no
.!?;: produced one oversize piece that was emitted whole. Now falls back to words, then to
characters for a genuinely unbroken run (a hash, a base64 blob).
LOW — piece offsets came from a running total that finditer's empty end-of-string match could
desynchronise from the source. Now taken from match.start(), so spans are exact by construction
rather than by accounting.
LOW (test-design) — a stand-in counter can be wrong in the direction that hides the bug. The word-counter says a 400-character unbroken run is one token; every real tokenizer disagrees. The character-cut path needed a token-dense counter to be exercised at all.
I7 — Embedding backends and reranker (20260725 15:35)¶
MEDIUM — a fake that could never disagree. The test backend was registered as
FakeBackend(section.dim), so it reported whatever width the manifest claimed — making the
dim-mismatch check, the one guard against storing incomparable vectors, impossible to test. Pinned
to a fixed width. Lesson: second time in three increments that a stand-in was wrong in exactly the
direction that hides the bug (I6's word-counter was the first). A fake that derives its answer from
the input under test asserts nothing.
MEDIUM — an assertion guessed at a real model's behaviour. count_tokens("retrieval augmented
generation") > 3 was written before any model had been run; the real BPE count is exactly 3, and
the test failed the moment weights were cached. Rewritten to assert the relationship (longer text
→ more tokens) rather than a number I had invented. Lesson: when a test crosses into a real
dependency, assert properties, not remembered values.
Verified against real weights (not inferred): fastembed's BAAI/bge-small-en-v1.5 gives
dim=384, max_seq_length=512 derived from the tokenizer's truncation config, normalised float32
vectors (self-cosine 1.0), and it wrote to ~/.cache/huggingface/hub rather than
$TMPDIR/fastembed_cache. BAAI/bge-reranker-base ranked a relevant passage above an irrelevant
one, -0.28 vs -7.85.
LOW (reference, matters for I14) — reranker scores are raw logits, not probabilities. They came
back around -0.28 and -7.85, not in [0, 1]. The illustrative thresholds in §2.1
(low_below = 0.31, high_above = 0.62) read like normalised scores; calibration must either fit
against the logit scale or squash it first. Recorded now so I14 does not quietly fit thresholds to
the wrong scale.
I8a — Sync pairing core (20260725 15:50)¶
MEDIUM — a model-test guard checked the wrong half (found in I7, surfaced here). The
model-marked tests skipped when weights were absent, but not when the backend was absent. Model
weights live in a shared machine-wide cache, so a worktree without the light extra installed still
saw them, ran the test, and failed with BackendMissingError instead of skipping. Only noticed
because a second worktree had a different install set. Now both halves are checked. Lesson: a skip
condition is a claim about the environment, and machine-wide state (a shared cache) is not evidence
about the local one.
Design note, not a defect — DuplicateIdsError raises rather than returning an action.
plans/20260725_1317-v0.1.md lists it among pair()'s return values. Raising is better: the condition is fatal
for the whole run, and an action every caller must remember to inspect is one a caller will
eventually forget. Recorded because it is a deliberate divergence from the reviewed plan.
What the exhaustive table bought. Writing one test per §6.4 row, then the compound cases the
table cannot express, is what forced the two decisions the design left implicit: a sidecar
disagreeing with the index wins (docs/ is truth, the index is derived), and a whole-picture rule
must be order-independent — asserted directly by pairing the same snapshot walked forwards and
backwards.
I8b — pnk sync, locking, and the rebuild swap (20260725 16:20)¶
HIGH — the rebuild swap left the old database's -wal/-shm behind. Design pass 2 fixed the
missing checkpoint; this is the other half nobody had noticed. SQLite names the companions after the
path, not the inode, so after os.replace they sit beside the new index claiming to be its
write-ahead log — the exact corruption the checkpoint was added to prevent, reintroduced by the
rename that followed it. They are now removed after the swap. Lesson: an atomic rename is atomic
for one file; a WAL database is three files with correlated names, and correctness arguments about
"the file" quietly skip the other two.
MEDIUM — a read-only SQLite connection creates -wal and -shm itself. This masked the bug
above: the test read the index before asserting the companions were gone, and the read created them.
Worth knowing beyond this test — §4.7 says the MCP server opens the index read-only "so it cannot
write", which remains true of the data, but the server does create files in .pinakes/. Any
future check that treats "no companions present" as evidence of a clean shutdown is wrong.
MEDIUM — a leaked connection inside a test helper. The helper was a generator, and a caller
using next() left it suspended, so its finally: close() never ran. The symptom appeared in an
unrelated assertion about rebuild. Now returns a list and closes immediately. Lesson: a generator
with cleanup in finally only cleans up if it is exhausted; for a fixture-shaped helper, return the
list.
LOW — ty caught a loose test shim pyright had been told to ignore. The monkeypatched
__import__ took *args: object; pyright was silenced with an inline ignore, ty flagged the real
mismatch. Typing the shim to __import__'s actual signature satisfied both and deleted the
suppression. First time the "fast pre-check" found something the gate had been told to skip —
noted, since I1's decision assumed ty would only ever be faster, not different.
I9 — Retrieval pipeline (20260725 16:55)¶
MEDIUM — vector search padded its candidate list with zero-similarity passages. argsort returns
every row, so a query sharing no direction at all with a passage still ranked it, and with nothing
better available those passages reached the user. Real models rarely produce an exact zero, so this
would have hidden until a sparse or domain-shifted corpus hit it. Non-positive cosines are now
dropped. Lesson: "return the top N by similarity" is only sane while similarity means something;
N is a cap, not a quota to fill.
MEDIUM (design gap, decided here) — the design said the filter set included "date", and no date
column existed. Documents carry mtime; a sidecar's created is optional, and filtering on an
optional field silently excludes every document that lacks it — worse than having no filter. The
filter is now mtime, and §4.1 says so. Lesson: when the design names a filter dimension, check
which column actually holds it for every row, not just the well-formed ones.
LOW — sqlite3.Row hands back Any, which erased types through the whole hydration path. Rows
are now narrowed once into a small frozen dataclass instead of being cast field by field at each use
— pyright strict was the thing that made this visible.
Worth recording: FTS5 escaping is not optional. AND, OR, NEAR, *, " and a bare
apostrophe are all parser syntax; a user typing it's would otherwise crash the query. Quoting each
word as a literal and joining with OR keeps recall — an implicit AND drops a passage for one
missing word, which is exactly the recall the vector half is there to provide.
I10 — pnk init, the notes template, pnk search (20260725 17:15)¶
MEDIUM — warnings were being printed and ignored. Turning filterwarnings = ["error"] on
immediately produced two real problems that had been sitting in the summary: importlib.abc.
Traversable is deprecated and removed in Python 3.14, so this project would have broken on the
next Python it claims to support; and several tests leaked SQLite handles, surfacing as
ResourceWarning raised from wherever the garbage collector happened to run — never the test that
leaked. Lesson: a warning nobody has to act on is a warning nobody reads. Making them errors cost
one afternoon of cleanup and bought a Python upgrade.
MEDIUM (process) — I committed with a failing gate. The last edit before committing introduced a
reportUnusedFunction on an autouse fixture; I had run pyright before that edit, not after. Fixed
in the retrospective commit. Lesson: the gate belongs immediately before git commit, not
"recently" — and "recently" is exactly what it felt like at the time.
LOW — the test fixture violated a manifest invariant I had written myself. Setting
max_tokens = 60 while leaving overlap = 64 was rejected by I3's cross-key validation. Pleasant
confirmation that the check earns its place: the first thing it caught was its own author.
Design note — the template ships [retrieval.confidence] commented out. plans/20260725_1317-v0.1.md had
already decided this, and building it made the reason concrete: pnk init cannot know anything
about the corpus the user is about to add, so any threshold it wrote would be a number with no
provenance. confidence: unknown until they fit their own is the only honest default.
I11 — pnk doctor (20260725 17:35)¶
MEDIUM — ty found a second real defect hiding behind a pyright: ignore. I had typed a dict as
dict[Path, object] and silenced the resulting argument-type complaint rather than fixing the
annotation. That is the same shape as I8b's finding, and it is now a pattern: an inline
suppression is where a type error goes to be forgotten. Every pyright: ignore in src/ has been
removed as of this increment; the two that existed were both hiding something real, not appeasing a
checker that was wrong.
Worth stating — doctor is where several design promises stop being rhetorical. §3.1's linear
scan ceiling, §6.2's link-coverage ceiling, §4.2's unknown confidence, §6.4's orphan reporting and
§6.5's lock are all "we will tell you rather than pretend" commitments. None of them is honest
unless something actually prints them, and until this increment none of them did. The test that says
every non-OK check must carry a remedy is the one enforcing the spirit of it: a report that says
"problem" without saying "do this" is just anxiety.
Design note — an uncalibrated KB is a WARN, not a FAIL. Reporting confidence: unknown is the
honest behaviour the design chose, so a KB doing exactly that is not broken; it is uninformative,
and the warning says so with a pointer to §4.2 and §7.
I12 — pnk install-hooks (20260725 17:50)¶
Confirmed end to end, with a real commit: the three-hook split does what design pass 6 claimed.
docs/note.md and docs/note.md.pnk.yaml land in the same commit, and git status is clean
afterwards. That was the whole argument for splitting the hooks, and it is now a test rather than a
paragraph.
LOW — the pre-commit half needs no embedding backend at all, which only became obvious when a
subprocess pnk ran without the test's fake registered and the sidecar half still worked. That is
the right shape: minting an id is cheap and belongs before the commit; embedding is slow and belongs
after it. Worth recording because it means a KB whose backend is not installed can still commit
documents with correct, permanent ids — the failure is deferred to indexing, exactly where §4.5 says
a core-only install should feel it.
LOW (test hygiene) — a "tree is clean" assertion failed on files the fixture never committed. The hooks were fine; the setup was. An assertion about cleanliness is only meaningful from a clean starting point.
HIGH (process) — I committed with red gates for the second time, and now understand why. The
pattern uv run pyright 2>&1 | tail -1 && git commit … reports the exit status of tail, which is
always 0. Both checkers were failing and the commit went through looking green. This is not
carelessness that can be fixed by resolving to be careful: the shell was reporting success. Added
check.sh, which runs every gate under set -e, and pointed CLAUDE.md at it. Lesson: if a
safety check is a pipeline, the thing you are checking is the last command in the pipe. Make the
gate a script that exits non-zero, and the mistake becomes unavailable.
I13 — pnk serve (MCP) (20260725 18:15)¶
The boundary is testable, so it is tested. §4.7's claim is that an agent cannot reach outside
the KBs the server was pointed at. Three tests hold it: pinakes_get refuses a path, a traversal
string and an unknown ULID identically; a KB that exists on disk but was not passed on the command
line is unreachable and the error says arguments select by name or ULID never by path; and a
document deleted since it was indexed cannot be fetched.
MEDIUM — stat()-based staleness detection works, and the test proves the thing the design
argued about. After a --rebuild swap the server returns the new documents. The design's
reasoning (an open handle pins the old inode, so meta.build_id read through it would report the
old build forever) is now backed by a test that would fail if someone "simplified" it back.
LOW — pyright strict flags decorator-registered functions as unused. @mcp.tool() returns
something pyright cannot tie back to the name, so all three tools looked dead. Rather than
suppressing it, they are now registered in an explicit loop — which also makes the set of exposed
tools one readable line instead of three annotations. A suppression would have been the third one
this session that turned out to be hiding something; making the code say what it means was cheaper.
LOW — another leaked connection, caught by warnings-as-errors from I10. A Server built inline
inside a pytest.raises block was never closed. This is the third leak that setting only found;
before I10 it would have been invisible.
I14 — Demo KB, golden set, eval harness, calibration (20260725 19:00)¶
HIGH — the two error rates were a flattering zero, and it took looking at real numbers to see
it. The first eval run reported false_abstain: 0.0 and false_confidence: 0.0. Both were
vacuous: the demo KB had no fitted thresholds, so confidence was always unknown, so neither
error could ever be counted. A CI gate on false-confidence would have passed forever, and passed
loudest exactly when calibration was missing. Added confidence_coverage to the metrics, and made a
drop in it a regression in its own right. Lesson: a perfect score on an error rate is a claim that
deserves the same suspicion as a failing one — check the denominator before believing the ratio.
HIGH — the first threshold formula made low unreachable. low_below = min(answerable) came
out at -9.885 on real logits, a floor almost nothing falls below, so the system could essentially
never abstain and false-abstain was zero by construction. Both thresholds are now fitted from the
unanswerable distribution — the only outcomes known absolutely — with low_below its median and
high_above a high percentile. Only visible because the fit was run against real reranker scores
rather than a fake's tidy 0-to-1 range.
The measured cost of the confidence heuristic, stated plainly: false-confidence is 0.25. One
no-answer question in four still gets a confident answer, because the score distributions genuinely
overlap (answerable -9.9..7.9, unanswerable -8.3..-2.7). §4.2 promised this would be measured rather
than assumed; it is now in docs/DESIGN.md's risk table with its date and models. Two caveats are
recorded with it: eight no-answer questions is a small sample, and the thresholds are fitted on the
same set they are scored against, so it is a floor rather than a measurement.
MEDIUM — the test fixture copied .pinakes/ and ran the 64-dimensional fake against a
384-dimensional index. I4's stored-vector width check refused it, which is exactly right and
briefly baffling. Generated state is now excluded from the copy. A good sign for the guard: the
first thing it caught was a developer, not a user.
LOW — ruff caught assert ... or True in my own test. The tautology lesson from I2, third
appearance, this time found by a linter rather than by reading. SIM222 earns its place.
I15 — CI, packaging, 0.1.0 (20260725 19:30)¶
MEDIUM — the version lived in two places. pyproject.toml and __init__.py each carried it,
which is one place to forget on every release. Hatch now reads it from the module, and the release
workflow refuses a tag that disagrees with it — a mismatched tag is the kind of thing nobody notices
until an install pulls the wrong thing.
The wheel smoke test earned its place immediately. pnk init reads its template through
importlib.resources, so a packaging mistake is invisible in the source tree and total after
install. Running pnk init from the built wheel — and asserting the manifest, the golden-set stub
and the .gitignore all appear — is the only check that would have caught it.
Publishing is left as a human step, on purpose. The release workflow runs on a v* tag and
nothing else, and PyPI trusted publishing has to be configured in the PyPI UI first. Neither the tag
nor the publish happens automatically from a merge: an irreversible, outward-facing action should
need someone to mean it.
§8's v0.1 sentence, walked item by item: 17/17 present. The plan asked for that walk explicitly at this increment rather than trusting the accumulated sense that everything got done.
Post-v0.1 release housekeeping (20260727 15:35)¶
Not an increment — a session that merged the graph research, closed out the v0.1 plan, and cut the releases that had never been cut. Recorded because four of its findings are the kind that cost more to rediscover than to write down, and one is a mistake made in this session by the person writing this.
The 0.1.0 release existed in every artifact except the ones that matter. __version__ said
0.1.0, the CHANGELOG had a dated [0.1.0] section, and its footer linked
releases/tag/v0.1.0 — while git tag -l was empty, no GitHub release existed, and PyPI returned
404. The release workflow fires only on a v* tag, so nothing had ever been built or published.
Lesson: a version number is a claim, and a claim in a CHANGELOG is the easiest kind to believe.
Verify a release the way a stranger would — git tag -l, gh release list, the package index —
never by reading the file that asserts it.
A docs-only merge turned main red. ruff format --check . formats Python fenced blocks
inside Markdown, so an igraph snippet in a research doc failed the Format gate. The instinct that
a documentation change cannot break the build is wrong in this repo, and the gate is the only
arbiter. Now stated in CLAUDE.md and in the README's Development section.
Merging from inside the feature worktree silently does nothing, and the tag lands off-main.
Running git merge --ff-only <branch> while cwd is that branch's own worktree merges the branch
into itself — "Already up to date" — and the subsequent git push origin main reports "Everything
up-to-date" because the local main ref never moved. The git tag that followed pointed at a
commit reachable only from the branch, so v0.1.2 existed, was pushed, and was not an ancestor of
main. Both commands succeeded; nothing failed loudly. Lesson: merge from the primary
checkout, and before creating a release assert the lineage —
git merge-base --is-ancestor vX.Y.Z main.
The README was the only surface that lied. An audit against the running CLI found four claims
contradicting the code: pnk ask --deep described as existing (it is a v0.4 plan), a budget ledger
described as tracking spend (nothing writes one), install lines pointing at a PyPI package that
404s, and the headline KB diagram built on a .pdf — the one file type v0.1 cannot ingest.
cli.py and the CHANGELOG were scrupulous in the same places, both saying "planned for v0.4".
Lesson: prose drifts toward the design and away from the build, because the design is what the
author is thinking about. The check that works is running the commands the README shows. The
documented [light] install had the same shape: co-equal in the README, broken at the first sync
because pnk init always stamps sentence-transformers.
A promise in a section with no increment number belongs to nobody. plans/20260725_1317-v0.1.md asked for a
CI grep gate keeping paid-API clients out of src/ under "Verification of the whole". No increment
owned it, so it never shipped — while the invariant it guards is the one CLAUDE.md calls
non-negotiable. Now enforced, and verified in both directions: it passes on the current source and
catches a planted import openai. Lesson: every promised check carries an increment number and a
path, or it is a wish.
Planning v0.2 (20260727 17:00)¶
A review pass is a change, and a change needs its own review. Adversarial pass 2 over
plans/20260727_1543-v0.2.md returned 5 HIGH — and four of the five were created by pass 1's own fixes, not
survivals of pass 1's findings. Pass 1 correctly rejected a per-page cost estimate as an
order-of-magnitude under-reservation against whole-document requests; the shape it introduced
instead was quadratic in input and stopped fitting the model's context window at ~166 pages, so a
100-page document reserved ~$375 and the feature failed closed with a refusal no user could satisfy.
Lesson: never implement from the revision that a review produced. Two passes are the floor for a
document of this size, and each pass reviews the previous pass's fixes, not only the original.
A threshold that only exists in tests/ protects nobody. Both fitted floors — the one that
triggers paid re-extraction and the one that stops a paid run on an already-healthy PDF — were
committed to tests/pdf-corpus/baseline.json, which no wheel installs, while three runtime
consumers on a user's own KB depended on them. The fail-closed rule was also stated for one floor
and not the other, so the guard against paying to re-extract a healthy PDF was silently disabled
everywhere. Lesson: a value a user's runtime reads is package data, and every fail-closed rule is
stated and tested once per consumer, not once per document.
An append-only ledger needs a way to say "this never happened". Every paid call wrote a
reservation before the call and a reconciliation after, with an unreconciled reservation counted at
its reserved amount. A call that raised — timeout, 429, 5xx — therefore left spend that nothing
could ever close, in a file CLAUDE.md forbids editing: a handful of transient failures would lock
a user out of a monthly budget permanently. Lesson: a two-state protocol over an append-only log
needs a third state. Reserve → reconcile or void.
Timestamps were composed instead of read. Four "verified on 20260727 17:34" claims were written
at 17:00 — 34 minutes in the future. Session context carries a date but never a time, so any HH:MM
not read from the clock is invented, and an invented one lands in the future about half the time. A
timestamp exists to say how fresh a verified claim is, so a fabricated one is a false evidence claim
rather than a formatting slip. Lesson: run date "+%Y%m%d %H:%M" and paste the result; one call
covers a batch of edits. Now a rule in CLAUDE.md.
Planning v0.2, pass 3 (20260727 20:23)¶
Three review passes, and each one's largest source of new HIGHs was the previous pass's fixes. Pass 2 found that four of its five HIGHs were created by pass 1's repairs; pass 3 found the same of five of its twelve. Reviewing a plan is a change to the plan, and a change needs its own review. Lesson: budget for the review of the review. A plan is not ready because the last pass was clean — it is ready when a pass over the current text is clean.
What broke the cycle was method, not effort. Five of pass 3's twelve HIGHs were found only by
checking the plan against src/ and against arithmetic — never by reading the plan, however
adversarially. --rebuild discards the index and reads its "before" snapshot from the empty new
database (sync.py:231-241), so a guarantee recorded only in index.db was invisible to the one
command that most needed it. A trace test asserted "exactly one chunk contains this offset" while
chunk.py:239-269 prepends the carried overlap and takes start from the carried piece, so
chunk n+1 begins inside chunk n whenever a block splits. A filter test named tags as a column;
it is json_each over a NOT NULL DEFAULT '{}' field, so the assertion was true by schema for
every row and would have passed on a corpus with no tags at all. A raster gate promised "a moved
word must fail" against a threshold its own arithmetic puts about four lines of text away. And the
docs sweep enumerated README additions while four sentences already in the README are falsified
by the release — the same defect, and the same count, as the audit at 0.1.2.
Lesson: a consistency pass cannot find a claim that is internally consistent and externally false.
Run three narrow passes instead of one wide one — code-reality (resolve every claim about src/
against the file), arithmetic (recompute every stated number), and promise-ledger (walk every
enumerated bound, flag, floor, gate and amendment asking "which increment makes this true, and
which test proves it"). Each found HIGHs the wide pass missed.
A threshold you cannot fit yet is not a threshold — ship the metric, defer the loop. Two passes each tried to fit the completeness audit's floor, and each picked a pair that was not the pair it is applied to, because the applied pair needs paid model output that does not exist at fitting time. The second attempt would have fitted over a population including a fixture the reader contract requires to raise, landing the floor near zero and leaving the audit inert on every installed copy. Lesson: when a threshold's correct fitting data cannot exist yet, that is the finding. Ship the measurement, report it, and let the release that produces real data fit the number.
"Bypasses module X" is a claim about an import graph, and it was false. The paid extractor was
documented as bypassing layout.py — while calling normalise(), which lives in it. The version
constant covering that module was then deliberately excluded from the paid fingerprint, so a
whitespace or ligature change would have missed every free cache entry and silently hit every
paid one, with the coherence check unable to see it. Lesson: when two consumers share one stage,
version that stage separately rather than arguing about which of them "really" runs it.
Planning v0.2, pass 4 — three narrow passes (20260727 20:57)¶
Splitting one wide review into three narrow ones is what finally found the structural defects.
Passes 1–3 were each a thorough adversarial reading, and each one's largest source of new HIGHs was
the previous pass's fixes. Pass 4 ran instead as a code-reality pass (resolve every claim about
src/ against the file), an arithmetic pass (recompute every stated number), and a
promise-ledger pass (walk every enumerated flag, floor, gate, constant and amendment row, asking
only "which increment makes this true, and which test proves it"). Together they returned 19 HIGH —
and almost none of them were things a fourth general reading would have caught.
Lesson: a reviewer reading for coherence finds incoherence. Defects that are internally consistent
and externally false need a pass that leaves the document: to the source, to a calculator, or to a
checklist. Run those as separate passes, because each is a different kind of attention.
A review pass's own fixes are the highest-risk text in the document. Four of pass 2's five HIGHs
came from pass 1's fixes; five of pass 3's twelve came from pass 2's; and two of pass 4's came from
inside pass 3's fixes — a pnk budget ordering bug fixed by assigning the edit to an increment
that lands earlier, and a missing-amendment-row sweep that added a row for one copy of a sentence
and missed two others. The arithmetic pass caught the sharpest case: pass 3 replaced a raster
tolerance that could not detect a moved word with a different tolerance that also could not detect a
moved word. Lesson: never ship the revision a review produced. Re-review the fixes specifically,
and prefer a method that cannot be fooled by the same reasoning that produced them.
"The code already does X" is a claim, and this project keeps getting it wrong. Verified against
src/: write_sidecar runs only for newly minted documents, so a plan built on "sync writes the
sidecar" had no write path for the case it cared about; pnk init --ci was designed in DESIGN and
never built, while an increment was written to modify it; CI installs --extra light and runs the
model tests in the check job, so a new matrix leg would triple them rather than skip them; and
--index-only, which the hooks run, is contractually forbidden from writing into docs/ at all.
Lesson: every plan sentence about existing behaviour carries a file:line, or it is a guess.
A threshold needs enough data to have chosen it. The running-head threshold T was documented
as fitted over a stratum of 3-page fixtures — where per-document recurrence can only be 1/3, 2/3 or
1, so every T in (1/3, 2/3] reproduces the corpus identically. The value was real, the fit was
not. Lesson: state a fitted threshold's resolution alongside its value. If the corpus cannot
distinguish 0.4 from 0.6, "fitted" is a claim the data does not support — enlarge the fixture or
call it a chosen constant.
I1 — extras, the extractor seam (20260727 22:28)¶
An explicit, textual exit criterion is not met just because the surrounding tests feel thorough.
The plan's own words were "the filterwarnings probe and both marker predicates get named tests" —
the probe got tests; pdf_runnable()/paid_runnable() did not, and nothing caught it until the
review grepped for their names and found only their own definitions. Twenty-odd other tests passing
made the increment feel covered. Lesson: when a plan states a test-coverage exit criterion in so
many words, grep for the named thing before calling the increment done — a feeling of thoroughness
is not the same claim as a named test existing.
A synthetic probe path that doesn't mirror the real call site's resolution semantics passes on the
easy case and is silently wrong on the common one. doctor._could_match_pdf checked whether
sources.include could ever match a PDF by testing patterns against a probe path prefixed with the
root's own name ("docs/__pdf_probe__.pdf") — but walk_sources applies each pattern via
root.glob(pattern), where root is already resolved, so a pattern is relative to it, never to
the KB root. The bug was invisible in-repo because the one test written for it used **/*.pdf,
which happens to match regardless of the extra prefix; a bare *.pdf — an equally ordinary
manifest — silently reported OK: not installed (no .pdf in include) on a KB that would, in fact,
fail its very next pnk sync. Lesson: a synthetic probe is only as good as its fidelity to the real
resolution path, and one test shape that happens to tolerate an error is not evidence the error
doesn't exist — test the shape closest to the literal documented example, not only the most generic
one.
The docs-in-the-same-commit rule missed two files because neither is named a "user-facing
surface." DESIGN.md and CLAUDE.md were amended correctly in the same commit that made CI a
three-leg matrix; README.md's and the Makefile's own make install comments, both reading "as CI
does," were not — the exact class of drift a 0.1.2 audit already caught once for this project
(above). Neither file is a flag, a manifest key, or a --help string, so neither felt like it was in
scope. Lesson: "describes CI/build behaviour in prose" is as much a user-facing surface as a CLI
flag — grep README.md and the Makefile for the thing that changed, not only cli.py and
DESIGN.md.
I2 — the synthetic PDF corpus (20260727 23:43)¶
HIGH — a gate rendered at a resolution that made its own threshold unreachable. The scanned
stratum's tolerance is "more than 300 pixels differing by more than 32 levels", derived for a page
rastered at 150 dpi. The comparison called page.render(scale=1.0) — pdfium's default of 1 px per
point, i.e. 72 dpi — which downsamples the stored 150 dpi image ~2× before diffing. That
shrinks the page from 2,105,025 px to 485,316 and a moved word's delta from several hundred pixels
to well under a hundred: the gate would have passed exactly the change it exists to catch, while its
docstring claimed a 2× margin. The docstring also named A4 (1240×1754) for a corpus that is US
Letter throughout, so both factors in the derivation were wrong and they partly cancelled, which is
why the number still looked plausible. Lesson: a tolerance is meaningless without the resolution it
is measured at — state both, and assert the comparison runs at the fixtures' own. When a derived
constant is checked, re-derive it from the code that consumes it, not from the prose that
introduced it.
HIGH — textwrap invented a hyphen the ground truth then rendered as a phantom space.
textwrap.wrap(..., break_long_words=False) leaves break_on_hyphens=True, so it split the
existing compound "spine-out" across two lines; the ground truth joins lines with a space, yielding
"spine- out" — a string no correct extractor could ever produce, in the one file whose entire job is
to be what a correct extractor produces. The same word appears correctly in 16 other places in the
corpus, so the corpus contradicted itself. Lesson: when a helper's default silently edits content,
the edit shows up wherever the content is re-joined by a different rule. Hyphenation is exercised
deliberately here by fixtures that place their own hyphens; it must never arrive by accident.
HIGH — the soft-hyphen fixture's ground truth dropped the first four words of its own page. The expected text began "cooperation agreement…" while the page reads "The clerk filed the coopera- tion agreement…". Written by hand while thinking about the joined word, not about the page. Every automated check still passed: the fixture had a ground truth, the counts matched, the bytes regenerated. Lesson: nothing in a corpus's own test suite can tell you a ground truth is wrong — only reading it beside the page can. Budget for that reading explicitly; "the tests are green" is not evidence about the one thing tests cannot check.
MEDIUM — two claims with no enforcement, in a file full of enforcement. "Pillow is dev-only,
never core, never an extra" was stated in the commit message, in conftest.py and in a test
docstring — and adding Pillow to [project.dependencies] left the whole suite green, while the
structurally identical claim for pypdfium2/anthropic is tested. Separately,
pdf_runnable()'s three-part predicate had a test walking false→false→false→false→true that never
turned the corpus clause off once both libraries were on, so deleting that clause entirely still
passed. Lesson: for an N-part predicate, assert each part is individually load-bearing by turning
it off from the all-true state — a monotonic walk up to true proves only the last flip mattered.
LOW — the same quantity was stated twice, differently, and neither figure was right. The commit
message said ~440 KB (that is du's block-rounded disk usage of the whole directory), the CHANGELOG
said ~370 KB (unreconstructable), and the budgeted quantity — the PDF bytes test_byte_budget sums
— is 266 KiB. Lesson: when a number appears in two documents, both are guesses unless one of them
was measured; measure once and paste the same figure.
I3a — extraction core, pure: chars to ordered, de-furnished text (20260728 00:52)¶
HIGH — column clustering compared each candidate to the wrong reference point, letting drift
accumulate past its own threshold. reading_order grouped blocks into columns by comparing each
new block's x0 to the last-placed member of the current column, not the column's start. Sorted
by x0, each step can individually stay under _COLUMN_GAP while the column's accepted range walks
steadily rightward — so a genuine third column, far enough from the first to be its own column, could
still merge into the second's cluster one small step at a time. Caught with a reproduction script
laying out three real columns and reading the wrong (merged) order back. Lesson: "cluster by gap"
needs a fixed anchor — the cluster's start, not its most recent member — or the gap check bounds a
single step while saying nothing about total drift.
HIGH — y-band clustering by round() put a hard wall at every half-integer. strip_running_heads
grouped running-head candidates into y-bands with round(block.y0). Two renderings of one genuine
running head at 750.4 and 750.6 pt — sub-point jitter, far smaller than any real layout difference —
round to 750 and 751: two distinct, non-recurring signatures, each individually under the suppression
threshold even though the line recurs on every page. Fixed with tolerance-based clustering (shared
anchors, abs(y0 - anchor) <= _RUNNING_HEAD_Y_TOLERANCE) matching _LINE_TOLERANCE's own approach
elsewhere in the same file. Lesson: round() is a clustering method with an invisible discontinuity
at every .5; anything claiming "the same, allowing for rendering jitter" needs a tolerance compare,
never a shared rounding function, or the false-boundary cases won't show up until real PDFs hit them.
HIGH — the import-purity test only recognised import X, not from X import Y. _imported_names
walked ast.ImportFrom nodes and recorded node.module only. from pinakes.extract import layout —
the exact style layout.py itself already uses for its own dependency on ExtractedText — resolves
to the module name pinakes.extract, plus the separately recorded name layout; the check for
"extract.layout" matched neither, so textpolicy.py could have imported layout.py this way and
test_textpolicy_is_pure_and_does_not_import_layout would have stayed green. Fixed by folding
ImportFrom.names into fully-qualified names (f"{module}.{alias.name}") alongside the bare module.
Lesson: an import-graph test written against ast.Import habits misses ast.ImportFrom entirely
unless it's built and then attacked with the exact style the file under test itself uses.
MEDIUM — a page's dominant font size was voted on by character, not by line, so a verbose heading
could out-vote the body size it was meant to be measured against. _mode_font_size originally took
one entry per character in blocks_from_chars; a short body line has few characters, a heading with
a long title has many, and counting per-character let a sufficiently wordy heading tip the "mode" size
to its own, inverting line_size > body_size for the very line it should have flagged as a heading.
Fixed to take one entry per line ([max(c.font_size for c in line) for line in lines]). Lesson:
"most common value" needs its unit stated explicitly — voting by the wrong unit of measurement
produces a plausible-looking answer that is wrong in exactly the cases with more text, which are also
the cases most likely to be headings.
MEDIUM — a symmetric rule was checked on one side only, twice, in different functions.
join_hyphenation skipped a block as a join source when it was suppressed, but not when it was
itself a heading — so a heading ending in a hyphen could be joined into as a source, even though
the same function already refuses to join into a heading as a continuation. Separately, assemble
silently produced a truncated document if a block's page_index ever fell outside range(len(pages))
— a caller bug that should be loud (I3b's future pdfium adapter is the only caller that will ever
construct page_index), rather than a quietly shorter ExtractedText with no error at all. Both fixed
in the same pass: the heading check now runs both ways, and assemble raises RuntimeError on an
unplaced block. Lesson: when a function enforces "never X across a boundary," check both directions
explicitly — a docstring that says "either side" is a claim, not a guarantee, until both sides have a
test — and prefer a loud failure over a silently smaller correct-shaped result whenever "silently
smaller" is a shape invariants alone can't distinguish from correct.
LOW — fixing the "no filesystem access" gap introduced a fragile substring check, caught before it
shipped. Extending the import-purity test to also assert no os/pathlib/io import used the same
marker in name substring style already used for PDF libraries ("pypdfium2" in name). Re-deriving
what that check would actually match against layout.py's real imports first — rather than trusting
that it passed — showed "io" in name matches typing.Optional and collections.abc.Iterable
(...t-i-o-n...), neither of which touches a filesystem; the check would have false-positived the
moment either was ever imported. Fixed to match on the module boundary (name == module or
name.startswith(f"{module}.")) before it was ever committed. Lesson: two-letter module names are not
safe substring needles — os/io collide with ordinary English inside almost any longer identifier
— so "does this file import X" must match on the dotted-name boundary, never bare containment.
I3b — the pypdfium2 adapter, extraction-quality metrics, and the two fitted floors (20260728 03:06)¶
HIGH — an empty page list is not an empty request; it is pypdfium2's spelling of "every page."
slice_pages(path, first, last) clamped last to the document's own last page but never validated
that first still fell before it. Whenever first > last after clamping — a reversed range, or a
first entirely beyond the document — range(first, last_clamped + 1) is empty, and an empty list
is falsy in Python: pypdfium2's own import_pages treats a falsy pages argument identically to
pages=None, its own spelling of "import every page." Verified directly against the real 12-page
baseline-12p.pdf: slice_pages(5, 2), slice_pages(100, 200) and slice_pages(20, 30) each
silently returned all 12 pages, no exception. slice_pages is stated as I7b's future paid-path
request unit; a future off-by-one computing a page window (the last window of a document whose
length isn't a multiple of the window size is the obvious candidate) would have silently sent the
entire document to a paid API instead of a small slice — a cost-control failure, not merely a
wrong answer, in a project whose one hard invariant is that the free path stays free and spending is
never accidental. Fixed with explicit validation (first >= 0, first <= last_clamped) before the
range is ever built, raising ValueError. Lesson: an empty collection is not automatically "no
items requested" to the function receiving it — some APIs (documented or not) treat empty/None
identically as "unfiltered," and a range-clamping function must validate the range is still
non-empty itself, not merely non-negative.
MEDIUM — "wide relative to the page" and "spans multiple columns" are not the same fact, and only
one of them is safe to test for. reading_order's spanning-block detection was measured against
exactly one fixture (two-column-b.pdf's caption, 79% of the page's content span, against a 42%
maximum for any genuine column line) and shipped as a fixed fraction, _SPANNING_WIDTH_FRACTION =
0.6. An independently-constructed asymmetric layout — a narrow sidebar beside a much wider main
column, a real and common shape, not a contrived one — put the main column's own lines at 77% of
the page's content span with nothing in it actually overlapping the sidebar at all; the
width-fraction test misread every line of the wider column as spanning and interleaved the two
columns line by line, silently, with no error. Fixed by replacing the global-width test with a
geometric one: a block is spanning only if its own x1 reaches at or past the next column's own
x0 — genuinely bridging into that column's territory, which the caption does (its x1 passes the
right column's x0) and the wide sidebar-adjacent column does not (there is nothing to its own
right to reach into). Both the original caption case and the new asymmetric-column case are now
committed regression tests. Lesson: a measurement taken from one fixture is a fact about that
fixture, not evidence the derived threshold generalises — check whether the underlying mechanism the
threshold approximates (here, "does this block's own geometry actually overlap another column's")
can be tested directly instead, before shipping the approximation.
MEDIUM — a fitting function that raises on a missing upper bound but silently guesses at a missing
lower bound is not applying one policy, it is applying two, only one of which is stated.
fit_running_head_threshold raised loudly when no true-positive recurrence was ever observed, but
silently fell back to max_true_negative = 0.0 when no true-negative was ever observed — and a
lower fallback threshold makes strip_running_heads more aggressive, so this fallback was
assuming the best case (no decoy content ever recurs) with no evidence for it, dormant only because
the current corpus happens to have 76 true negatives. Made symmetric: both empty cases now raise.
Refactored the pure midpoint arithmetic out of the corpus-walking function
(threshold_from_fractions, taking fraction lists directly) specifically so both raise paths are
covered by a direct unit test, not only reachable in principle through a synthetic corpus directory.
Lesson: check every "if empty, fall back to X" for whether X is a measured true value (0.0
non-whitespace characters is the true yield of a page with no native text layer — the sibling
floor's own fallback, left alone) or merely a plausible-sounding guess standing in for missing data —
only the former is safe to leave silent.
MEDIUM — every extraction-quality metric whitespace-flattens its input by design, which means a
duplicated-newline regression is invisible to the very gate meant to catch regressions. The
\r/\n-character fix (dropping embedded line-break characters that were duplicating assemble()'s
own inter-block separator) shipped with zero regression coverage anywhere in the suite: reverting it
and re-running every test file, plus the real make pdf-eval gate end to end, produced zero
failures, because score_document's own documented design whitespace-flattens both extraction and
ground truth before scoring any of the five metrics. Added a structural test asserting the raw,
unflattened extraction contains no "\n\n" — verified to actually fail against the reverted code
before being trusted. Lesson: "every metric flattens whitespace by design" is a correct, deliberate
choice for what those metrics should measure, and also a standing blind spot for anything whose only
symptom is whitespace — a fix in that category needs its own structural test, in a file that doesn't
flatten, or it ships permanently unguarded.
LOW, bundled — three findings from the same review, each small alone. slice_pages with a
negative first leaked a raw pdfium.PdfiumError instead of the module's own ExtractionError
(resolved by the same upfront validation as the HIGH finding above, which now catches it before
pdfium is ever reached). test_check_script.py's guard test asserted three substrings existed
somewhere in check.sh, which stayed green even after deliberately replacing the real make
pdf-eval call with a no-op while leaving the explanatory comment above it untouched — rewritten to
match the actual if/then/else/fi block and assert where each string falls (inside then,
absent from else), verified to fail against the gutted version before being trusted. Rate's
numerator/denominator were typed float though every call site produces an int (character,
word, and pair counts, and sums of the same) — corrected to int, the type they actually are, with
no behavioural effect found. Lesson, shared: "does this test still pass if I break the thing it
claims to guard" is a cheap, five-minute check worth running on every new test before trusting it —
two of these three would have shipped a false sense of coverage without it.
Cross-platform scanned-fixture rendering — main CI-red since I2, unnoticed for three pushes (20260728 08:13)¶
HIGH — ./check.sh passing locally was silently substituted for "CI is green," and nobody was
checking which one had actually been verified. I2's merge, I3a's merge, and I3b's merge each
pushed to main believing the suite was green, because each one was green — on macOS, the only
platform any of them had run on. gh run list (prompted by the user asking to check GitHub
Actions, not by any check of my own) showed all three runs had actually failed on
check (light pdf) / check (light pdf claude), with the identical signature every time:
test_scanned_regeneration_within_tolerance — scanned-clean: 8006 pixels differ by >32 levels.
Three consecutive merges landed on a red main and every one of them was reported to the user as
successfully shipped. Lesson: "the local gate is green" and "CI is green" are different claims —
one is evidence for the other, not a substitute for it, and the project's own standing rule (check
actual CI status before building on top of a branch) applies with equal force to checking it after
pushing to one.
Root cause, confirmed empirically rather than assumed. Every text fixture referenced /BaseFont
/Helvetica with no embedded font program (pdfwriter.py, since I2), relying on the PDF reader's
own substitution for a font it doesn't have. pypdfium2 ships platform-specific prebuilt binaries;
macOS has a real Helvetica installed, ubuntu-latest does not, so pdfium substitutes a different
font on each — metrically compatible (identical word-wrap, identical line breaks) but with
different glyph outlines. The scanned stratum rasterizes baseline-12p through pdfium at
fixture-generation time, baking whichever platform generated it into the committed PDF, so CI's
own regeneration (on a different platform) could never match. Confirmed, not theorized: a Docker
ubuntu:24.04 container reproduced CI's exact number (8,006 px) on the first attempt, and a diff
heatmap of the two renders showed every changed pixel sitting exactly on a glyph edge — same text,
same word positions, same line breaks, different anti-aliasing. Measuring all ten scanned pages
found cross-platform noise ranging 507-8,262 px depending on how much text and how much the
contrast reduction already suppressed it (scanned-low-contrast came in far lower than
scanned-clean, consistent with the tolerance test's own documented noise-floor behavior).
The obvious fix (raise the tolerance) was rejected on evidence, not instinct. The measured
noise ceiling (8,262 px) sits far closer to the test's stated detection target — a single moved
word, "a small fraction of a page" per the test's own docstring — than to the documented
whole-page-shift signal (33,451 px) the 300 px threshold was originally sized against. Raising
MAX_CHANGED_PIXELS above the noise ceiling would have unblocked CI immediately, but a real
single-word regression is plausibly smaller than 8,262 px, meaning a tolerance wide enough to
absorb cross-platform noise would very likely also have absorbed exactly the class of regression
this test exists to catch — silently, with no way to tell from a green gate. Presented three
options (raise the tolerance / scope the test to one platform / embed a real font) with honest
pros and cons rather than picking unilaterally, since it's a public-repo, shared-CI-affecting
change to a previously "verified" threshold. Fixed at the root instead: pdfwriter.py now embeds
a subsetted TrueType font (tests/pdf-corpus/fonts/LiberationSans-Subset.ttf, SIL OFL 1.1 —
Liberation Sans specifically for its Helvetica/Arial metric compatibility, so none of
generate.py's hand-placed coordinates needed to change) instead of a bare base-14 name, so every
platform rasterizes the same outlines regardless of what fonts it has installed. Re-running the
identical Docker reproduction after the fix measured 0 pixels changed across every scanned page —
not merely under tolerance, bit-for-bit identical. Lesson: when a tolerance-based test's noise
floor turns out to be closer to its detection target than expected, re-measure what the tolerance
would need to become and check that against the smallest real regression it's meant to catch,
before touching the number — a gate that still passes is not evidence it still catches anything.
MEDIUM, from independent review — the fix itself shipped with no regression test. An
adversarial re-verification (independently reproducing the pre-fix failure and the post-fix 0-pixel
result in its own Docker run, and re-deriving the subset font byte-for-byte from a genuine Debian
package to confirm tests/pdf-corpus/fonts/README.md's recipe is real) found that nothing in the
diff would have caught a future revert to bare base-14 fonts: the only guard was the pre-existing
tolerance test, whose whole failure mode is passing locally and only failing on a second platform
— exactly what let this bug live unnoticed for three merges. Fixed with a platform-independent test
(test_text_fixtures_embed_a_font_program) asserting every non-scanned fixture's committed bytes
contain /FontFile2 and never a bare /BaseFont /Helvetica — verified to actually fail against
89d4fb5's committed fixtures before being trusted, and deliberately written with no pypdfium2
dependency so it runs even on the [light]-only CI leg. The same pass found _font_widths'
first_char hardcoded at 0x20 with no symmetric downward extension (unlike last_char), which
would have indexed _ASCII_WIDTHS negatively for a hypothetical differences code below 0x20 —
unreachable today (grepped every Font(...) call site) but fixed to be symmetric regardless. Lesson:
"the existing test would have caught this eventually, on some other platform" is not the same claim
as "this increment shipped its own regression test" — a fix for a bug that was invisible on one
platform needs a check that doesn't depend on which platform runs it.
I4 — the extraction cache (20260728 10:28)¶
MEDIUM — a filename collision between a real cache entry and its own in-flight write. Every
scanning function (survey, total_stats, clear_all) globs *.json; _write's atomic-write
temp file was originally suffixed .json too (.tmp-<random>.json, meant to land beside the final
name before os.replace). Verified directly: pathlib.Path.glob("*.json") matches dot-files,
unlike shell globbing, so that temp name was scanned as a real entry by every one of them. The
window only opens on an uncatchable kill (SIGKILL, OOM, power loss — _write's own
except BaseException already cleans up anything else), but inside it a stray file could be
double-counted in pnk doctor's totals forever (unreachable via the keyed entry_path() lookup, so
a fresh real entry gets written alongside it, never replacing it) and, worse, misclassified as a
paid orphan if the abandoned write happened to carry an operation_id — read by the one thing
this module exists to protect. Fixed by suffixing the temp file .tmp, never .json, so it can
never match the glob regardless of the leading-dot question. Lesson: when several functions all
key off "does the filename match this pattern," a temp/staging file used by the same module needs
its own, deliberately non-matching pattern — matching the final name's extension by habit (.json
in, .json out) is exactly how a temp file becomes indistinguishable from a real one.
LOW-MEDIUM, no live trigger today — a JSON round-trip validated key structure but not value
types. _read's reconstruction of per_page_provenance did dict(page) for page in provenance
with no check that a page's values were actually strings, unlike page_spans three lines above
(int(span[0]), int(span[1])) — the same rigor wasn't applied to both fields reading from the
same untrusted JSON. Verified directly: a hand-written entry with {"confidence": None} was
accepted as a clean cache hit, silently degrading ExtractedText.per_page_provenance's declared
Mapping[str, str]. No live writer currently populates a non-empty per_page_provenance (pdfium.py
and the fake backend both rely on the dataclass default), so this was unreachable in practice —
but the cache exists precisely to survive untrusted/older/hand-edited files, and I5/I7b are exactly
the increments that will start writing real provenance. Fixed with _string_mapping, validating
every key and value before trusting the entry. Lesson: "no code currently writes the bad shape" is
a fact about today's callers, not a property of the format — a cache that reads its own JSON back
should validate every field it reconstructs the same way, not just the ones a current caller happens
to populate.
LOW — two verified-true claims shipped with no regression test. A cache-write failure
(chmod 0o500 on the cache directory, reproduced directly) correctly returns the extraction result
without raising, matching the contextlib.suppress(OSError) comment's claim — but nothing asserted
it. Two documents sharing one content_hash within a single KB correctly keep their shared cache
entry after one of them is deleted (eviction keys on the hash, which is still claimed by the
survivor) — only the cross-KB duplicate case had a test. Both were already correct; both now have
a test, one of which (the write-failure case) was verified to actually exercise the except OSError
branch by checking no cache file was created afterward, not merely that no exception propagated.
Lesson: an already-true claim without a test is one refactor away from becoming a false one with no
signal — "this works" and "this is tested" are different sentences even when both are honestly true
today.
Reviewed and found correct, not a defect: pnk sync --clear-cache (bare, no argument) deleting
paid cache entries along with everything else is the intended I4 behavior, not a gap — the plan's
own text ("removed only by an explicit --clear-cache=paid") describes a narrower, paid-preserving
variant that lands with I7c's ledger reader, which can price what it would be destroying; building
selective removal before that reader exists would mean guessing at a cost nothing can yet compute.
Confirmed live: an injected paid orphan was removed by pnk sync --clear-cache --yes along with
every other entry, consistent with clear_all's own docstring and docs/DESIGN.md's description.
I5 — PDF chunking, page provenance, and a backend-aware sync (20260728 13:59)¶
CRITICAL — decision 9's "never silently downgraded" guarantee held for a same-path sync and for
--rebuild, and silently did not for everything in between: a rename, or a document's first sync
on a machine that never extracted it. The initial design protected a paid extraction two ways:
pairing.py's own comparison when a document keeps its path, and --rebuild's copy-forward from
the old index. Neither covers Adopt/Rename outside a rebuild, which instead fell through to
_extract_for_index's only remaining signal — an extract/cache.py hit. A cache miss was then
read as "content changed", which is a false equivalence: a --clear-cache immediately before a
rename, or the first sync of a KB whose paid PDFs were extracted on a different machine (docs/
committed, .pinakes/ gitignored — the ordinary shape of a clone), miss the cache identically
without the file having changed at all. Confirmed live, both ways: (1) paid-index a PDF,
--clear-cache, rename it with its sidecar travelling, sync — landed a PaidExtractionRequiredError
falsely claiming changed content, and left the index describing the old, now-nonexistent path,
since the failed transaction rolled back before the rename could be recorded; (2) paid-index a PDF,
rm -rf .pinakes (index and cache, simulating a fresh clone), first sync — identical false
failure on a document that had never changed at all. An independent adversarial review (a fresh
subagent, unanchored on this increment's own design reasoning) found and reproduced both.
Fixed by moving the "has this changed" decision off the cache entirely: the sidecar's own
provenance.extraction gained a fourth field, content_hash — the file's hash at the moment of
that specific paid extraction, distinct from the general change-detection hash docs/DESIGN.md
§2.2 already refuses to store, since this one changes only when a fresh paid extraction runs.
Comparing it directly against the current file's hash answers "changed?" without consulting any
cache or index at all. Getting the actual text without paying again is a separate question, now
answered in a fixed order: this same sync's own connection (covers a rename — the row is still
there under its old path), the old index during a --rebuild (unchanged from the original design,
now keyed on doc_id rather than content_hash+path, since a rename's action only ever carries
its current path and the old index's row still has the old one), then extract/cache.py. Only
when none of the three has an answer does it fail — and now with a new, distinct error
(PaidExtractionUnavailableError) naming the file unchanged but its text simply not present on this
machine, never conflated with "content changed" (PaidExtractionRequiredError) again. The
cross-machine case (2) is not solved by this — there is genuinely no durable, shared home for a
paid extraction's text yet — but the failure is now honest about which situation it is, which
docs/DESIGN.md §9 records as an accepted, disclosed limitation rather than a silently discovered
one. Lesson: a mechanism justified as "the answer must not depend on the cache" (the --rebuild +
--clear-cache case this increment was explicitly designed around) needs that property checked
against every path that can reach the same decision, not just the one path the design started
from — pairing.py's own decision table has four ways into "paid, free-effective, unchanged", and
only one of them was ever traced all the way through.
HIGH — --rebuild turned "a paid document's content changed" into "the document vanishes from
the index", where a normal sync leaves it stale but searchable. pair()'s PaidExtractionRequired
action (decision 14) can only ever fire against a populated before, so it can never fire during a
rebuild at all (before is empty by construction) — meaning a changed-hash paid document reaching
--rebuild fell through to _extract_for_index's ordinary raise, caught by _apply's generic
exception handler, which never inserted a row for it in the first place. Confirmed live: paid-index
a PDF, change its bytes, pnk sync --rebuild — report.ok correctly False, but the document was
gone from the rebuilt index entirely (zero chunks), not merely flagged. A normal (non-rebuild) sync
hitting the identical case leaves the old row, chunks and embeddings untouched instead. Fixed by
extending the rebuild copy-forward to the changed-hash case too: the stale row is copied forward at
its old content_hash regardless of whether the current file still matches, and a failures entry
is recorded only when it does not — so a rebuild now reaches the same outcome a normal sync already
gives this exact case, rather than a harsher one purely as a side effect of which command happened
to run. Lesson: --rebuild is documented as "free, deterministic, cron-safe" — exactly the
description that invites treating it as interchangeable with a normal sync. Any new failure mode
this increment gives normal sync a considered answer for needs the identical question asked of
--rebuild explicitly, since its empty before makes "the same case" arrive by a structurally
different path that is easy to reason about in isolation and forget to reconcile.
MEDIUM — a fresh paid-provenance write left the very next sync one RefreshMetadata cycle away
from settling. _index_document decides sidecar_hash from the walk, before it rewrites the
sidecar with fresh provenance.extraction a few lines later in the same call — so the hash it
writes into documents.sidecar_hash was already stale the moment the write happened. Confirmed
live: three consecutive syncs of a freshly paid-extracted PDF produced embedded=1, then
refreshed=1 (unexpected — nothing the user did changed), then finally skipped=1. Fixed by
recomputing sidecar_hash from the file just written, whenever a write happened, before it reaches
the documents INSERT. Lesson: any function that both decides a hash-like value from an earlier
read and has the power to invalidate that exact read later in its own body needs the value
recomputed after the write, not carried from before it — "I already have this value" stops being
true the moment code between the read and its use can change what it should have been.
Caught live, during the adversarial review itself, not by this increment's own author: two
different documents can share one content_hash with only one of them ever paid-extracted (a
second document minted later for identical bytes gets its own ordinary free extraction). The
original rebuild copy-forward keyed its survivor lookup on content_hash alone, which would have
let the free twin's own rebuild incorrectly inherit the paid one's chunks, embeddings and backend
label. Found and fixed inline by the reviewing subagent (re-keying on (content_hash, path), later
superseded by the doc_id keying above once the rename/clone findings required a broader
redesign), with its own regression test. Lesson: a value that is "usually unique in practice" is
not a key — content_hash was never meant to identify one document, only to detect whether one
had changed; this table's actual primary key was sitting right there the whole time.
I6a — budget core, pure (20260728 17:52)¶
HIGH — every test of the timezone conversion that is this module's entire reason to exist would
have passed with the conversion deleted. window.py aggregates ledger records into day/month
totals in [budget] timezone, converting both now and each record's reserved_at before
comparing. Every test — including the midnight, month-end and DST-transition trio written
specifically to exercise attribution — constructed both values already in the target zone, where
.astimezone() is a no-op. The adversarial review mutated local_now = now.astimezone(timezone) to
local_now = now, and separately the per-record conversion, and both mutations passed all 35
tests. The real case is ordinary, not exotic: a ledger storing UTC timestamps (the obvious choice
for I6b) read under a non-UTC [budget] timezone — 23:30 UTC on the 15th is 00:30 on the 16th in
Berlin, so either mutation silently files the spend under the wrong day. Fixed by adding a test that
aggregates a UTC-stamped record against a Berlin window. Lesson: a test whose fixture is built in
the same units the code converts to cannot detect a missing conversion. Exercising a transformation
requires input where the transformation actually changes something — three tests that carefully
varied the clock while holding the timezone constant proved nothing about the timezone at all.
MEDIUM — an exception hierarchy copied from a sibling module inherited the wrong exception
types. prices.py was deliberately modelled on extract/floors.py, including its
except (TOMLDecodeError, KeyError, TypeError, ValueError) around parsing. But floors.py parses
with float(x), which raises ValueError on bad input, while prices.py parses with
Decimal(str(x)), which raises decimal.InvalidOperation — not a ValueError subclass
(InvalidOperation → DecimalException → ArithmeticError). A single-character price typo (a European
"5,00", an unfilled "TBD") therefore escaped as a bare InvalidOperation instead of the named
PricesMissingError the module's own docstring promises, and the test claiming to cover this only
ever exercised a TOML syntax error. Lesson: when mirroring a module's error handling, the except
clause travels with the parsing call it was written for. Changing float to Decimal changes which
exceptions are possible, and an inherited except tuple is a claim about the old code.
MEDIUM — validation absent at the one boundary where a wrong sign inverts the guarantee.
estimate_document accepted any pages/pages_estimated: pages=0 produced requests=0 and made
per_request_eur raise on a zero division, and a negative pages_estimated produced a negative
total_eur. Every other failure mode in the module — unknown model, missing prices, stale prices,
oversize request — had a named error, but the one that would make a budget guard understate spend
had none. Not reachable from any caller in this increment, since nothing calls it yet; guarded at
the source rather than trusting a caller that does not exist to be written correctly. Lesson: for
a component whose whole job is bounding a number, the input validation that matters most is
whichever one lets the number move in the safe-looking direction.
LOW, worth keeping — three assertions that were true but untested, in the same shape.
reserve_document's "every blocked window is named" was only ever tested with all three windows
breaching at once (so "always names all three" would also have passed); reserve's "first breach in
order wins" was only tested where a single window could breach (two of three caps were set to a
generous 100 in every case); and confirm_above_eur's strict > boundary was asserted only
incidentally. All three were verified correct by hand and none was a defect — but a regression in
any would have been invisible. Lesson: a passing test suite says nothing about the claims it never
puts under tension; the boundary cases worth writing are the ones where two plausible
implementations disagree.
Also fixed: Table.decimal()'s default path returned before its own minimum check, so a
below-minimum default would pass silently — integer()/number() avoid this for free by sharing
one code path between default and parsed value, which is why the bug was invisible by analogy.
ContextWindowExceededError's remedy told the user to lower a [chunking]-equivalent slice size
that does not exist as configuration (K is a fixed constant). Every fix in this increment was
confirmed to fail against the pre-fix code before landing.
I7a — the paid-path allowlist gate (20260728 19:25)¶
HIGH — the gate found two live paid-client imports on the free path, in code that had shipped.
doctor._extraction reported whether the configured extractor was available by calling
load_extractor(backend), and sync._missing_pdf_extra did the same to decide whether a skipped
.pdf still needed an extra. The registry's factory imports the client (extract/__init__.py's
_import), so on a KB whose [extraction] backend is claude-vision, both pnk doctor and
pnk sync pulled anthropic into a free-path process — doctor on every run. Neither was
reachable from any test, because every test KB is configured for pypdfium2. Nothing could
actually spend (the extractor is an I7b stub), so the cost was an import rather than a charge —
but the invariant CLAUDE.md calls non-negotiable was already false when the gate arrived to check
it. Lesson: "does the free path import a paid client" is a question about a running process,
not about the source text. A grep over src/ was green the whole time both leaks existed, because
neither leak is an import statement — it is an ordinary function call that reaches one.
HIGH — the gate's own paid KB was silently never paid. The free-path runner configures a second
KB for claude-vision, since that is the only configuration where the two probes above fire. It
did so with text.replace('backend = "pypdfium2"', ...) against a manifest template that has no
[extraction] section at all — so the replace matched nothing, the KB stayed on the free
backend, and the gate would have passed whether or not the leaks existed. Caught only because the
run printed pdf extractor: pypdfium2 importable for a KB that was supposed to say
claude-vision. Fixed by appending the section rather than replacing, by making every manifest
rewrite a _replace_once that raises when it matches nothing, and by loading the manifest back
through the real parser and asserting extraction.backend is what was asked for. Lesson:
str.replace returns the string unchanged when it matches nothing and reports it to no one — the
perfect way to build a test fixture that does not test what its name says. A fixture whose whole
purpose is to be in an unusual state must be read back through the parser and checked.
MEDIUM — two of the first six mutations survived, and both survivals were the finding.
Mutating is_backend_installed to return True passed the entire suite: its only two callers are
tested with it monkeypatched, so nothing exercised the function itself. And
test_a_directory_entry_fails_gate_1 asserted only a non-zero exit — which gate 2 produced anyway
by reporting the planted import, so the test stayed green with gate 1's directory branch deleted.
Fixed with three direct tests (including one whose probe module raises on import, so a
regression to importing errors rather than fails quietly) and by asserting gate 1's specific
message. Lesson: a function whose every caller stubs it out has no test, only agreement; and an
assertion on an exit code cannot tell which of two gates produced it.
MEDIUM — the runtime checker would have flagged google.protobuf as a paid client. Matching
sys.modules names by root (name.split(".")[0] in {"anthropic", …, "google"}) makes every
google.* module a hit, and protobuf arrives transitively with onnxruntime, grpc and much of the
ML ecosystem. Not triggered on this platform today — verified, zero google.* modules in a
free-path run — which is exactly what makes it dangerous: it would have fired first on some other
CI leg, on a change having nothing to do with money, and the obvious repair for a safety gate that
cries wolf is to weaken the gate. Now matched on a dotted-prefix boundary against
google.generativeai in full. Lesson: for a gate whose failure mode is "someone turns it off",
the false-positive direction is as load-bearing as the true-positive one — and a latent false
positive is worse than an active one, because it lands on an unrelated change.
LOW, worth keeping — gate 2 cannot see a dynamic import, and that is deliberate.
extract/__init__.py calls __import__("anthropic", …) inside the claude-vision factory, which
no import-statement grep will ever match, and it is not on the allowlist. Exempting the registry
would exempt the one file where an accidental static import is most likely; the dynamic call only
runs when a caller has explicitly selected a paid backend, which is an allowlisted entry point.
The limit is written into the gate's own docstring, and gate 4 is what covers the direction gate 2
cannot: no spelling of an import hides from sys.modules. Recorded so the next reader does not
"fix" gate 2 by widening the allowlist.
Also: pnk doctor no longer proves a paid backend's adapter constructs, only that its library is
locatable — the necessary price of not importing it, and worth remembering at I7b, when
_load_claude_vision stops being a stub. Every fix above was confirmed to fail against the pre-fix
code: 10 mutations planted, 10 detected, including both fixes made during this review.
I6b — budget I/O: the ledger, pnk budget, hooks that cannot spend (20260728 23:27)¶
HIGH — the accountant handed out a PaidCall object, undoing the one guarantee the ledger module
exists to provide. budget/ledger.py's whole argument is that a void may only be written when no
response was received, and that this is enforced by paid_call being a context manager rather than
a convention someone remembers. Accountant.open_call then wrote the reservation and returned the
object, putting both the void/unknown decision and the closing write back in the caller's hands —
and the caller it was written for is I7b's retry loop, the most branch-heavy code in the release.
Its own test left a permanent unknown outcome behind and asserted nothing about it. Now a context
manager delegating to ledger.paid_call. Lesson: an invariant enforced by a control-flow construct
is only enforced where that construct is actually used. A convenience wrapper one layer up is
exactly where it gets quietly opted out of, and "the module below guarantees it" stops being true
the moment a caller can hold the handle.
MEDIUM — a single bad character in the ledger could take pnk budget down entirely. Every euro
figure is cost_usd / usd_per_eur, computed in a property called long after parsing, from inside
the summing loop. A line with usd_per_eur of "0" therefore raised DivisionByZero out of a
read-only reporting command — defeating the malformed-line counting whose entire purpose is that no
one bad line can do that. The parse-time checks covered type (a JSON number is refused, so no
float gets in) but not domain. Rates are now validated positive at parse time, where a failure
is a counted malformed line. Lesson: validation placed at the parse boundary only protects what is
computed at the parse boundary. A derived value computed lazily elsewhere needs its inputs checked
where they enter, not where they are used.
MEDIUM — fsync on the file does not make the file's name durable. The reservation is written
before the call precisely so a crash during the call cannot lose it, and each write is fsynced. But
creating a file and fsyncing its contents leaves the directory entry unsynced, so the very first
reservation a KB ever writes — the one before its first paid call — could vanish on a crash while
every later one survived. The parent directory is now fsynced on creation only. Lesson: durability
claims have to name what is durable. "The write is fsynced" is a claim about bytes; "the record
survives a crash" additionally requires the file to still have a name.
MEDIUM — both hook checks read root/.git/hooks directly, so they were blind in a git worktree.
Inside a worktree or submodule .git is a file pointing elsewhere; hooks.hooks_dir has resolved
that since I12, and doctor never used it. Every hook read as absent, so pnk doctor reported "0 of
3 installed" and I6b's new machine-driven-spend check would have reported "no hooks installed, so no
automatic sync runs" on a KB whose hooks were installed and running. Worth more than its size here:
this project's own CLAUDE.md mandates a worktree for every change, so the layout the check is blind
on is the one it is developed in. Lesson: a helper that already handles a case is not the same as
using it — the second reader of .git reintroduced the bug the first one had solved.
LOW, worth keeping — one report, two clocks. pnk budget computes its windows in
[budget] timezone and printed the recent-operations list in the machine's local zone, unlabelled.
On a KB synced from two machines the same operation renders at two different times, and the day a
call is filed under would not match the timestamp beside it. Recorded because the fix is trivial and
the class of bug is not: a report that derives one number from configuration and another from
ambient state looks consistent on the machine it was written on.
LOW, second pass — two flags that read as the opposite of what they do. pnk budget listed the
five most recent operations and stopped, with no line saying how many it had not shown — the
silent-cap failure this plan's own ground rules name ("if a workflow bounds coverage, log() what
was dropped"). And --clear-cache's bare form parsed to the value free, which reads as "clear only
the free entries" when both spellings clear the whole cache; argparse validates const against
choices, so a private sentinel is not available and the bare form has to be a real, honest word.
It is now all, and the value names what is being authorised rather than what is removed.
LOW, third pass — the notice init --ci printed described a file it had not written.
FREE_BACKEND_NOTICE began "hooks run pnk sync --extract=pypdfium2 …" and was printed by two
callers: install-hooks, which writes hooks, and init --ci, which writes a workflow. So pnk init
--ci announced .github/workflows/pinakes.yml and then explained what the hooks do. Every test
asserted the flag and the phrase "can never spend", both of which were present and correct. Found
only by running the command the docs tell a user to run — the constant is now subject-less and each
caller supplies its own ("each hook …", "it …"). Lesson: a shared string with a subject baked in is
correct for exactly one caller. The tests all checked the part that was shared; the part that was
wrong was the part no assertion mentioned.
Also: doctor printed cost_eur — a Decimal division — with a bare f-string, putting all 28
significant digits into a health-check line; the --resolve record's operation field was
documented as a value it never takes. Every fix above was confirmed to fail against the pre-fix
code: 14 mutations planted over the implementation (14 detected), and 5 more reverting each review
fix in turn (5 detected), the last of which found that the formatting fix had no test at all until
one was written for it.
I7b — the paid Claude-vision extractor (20260729 00:24)¶
HIGH — the reconciliation recorded the reserved amount, which makes the whole
reservation/reconciliation protocol a no-op. _billed_call closed each successful call with
cost_usd=reserved_eur * usd_per_eur — the estimate again, not what the response said it cost. The
shape was perfect: a reservation, then a reconciliation superseding it, exactly as I6b's protocol
requires, with a ledger pair per call and every test about pairing passing. What it superseded the
reservation with was the reservation. Every window would have charged worst-case forever, pnk
budget would have reported an estimate as spend, and the reconciliation record's presence is
precisely what would have made it look settled. Fixed with actual_cost_usd, derived from the
response's own usage and the model's price. Lesson: I6b's tests could only ever check that a
reconciliation exists and supersedes; that it carries the right number is a claim only the
increment that produces the number can make. A protocol test and a value test look alike and are
not — and every mutation I planted over the retry logic passed straight through this, because the
bug was in the one line none of them touched.
HIGH — one bad PDF would have crashed a 1,000-document sync. TransportError and
RequestTooLargeError were plain Exceptions. sync isolates each document behind
except (PinakesError, OSError, ValueError), so an exhausted 429, a 500, or an oversized page
would have escaped that handler and taken the entire run down — the exact opposite of the
per-document isolation §6.4 promises and pnk sync's own "one broken PDF cannot block a
1,000-document corpus". Not caught by any test, because every test called extract_slice directly
and asserted pytest.raises(TransportError), which passes identically whichever base class it has.
Lesson: an exception's type is part of its contract with a caller several layers up, and a
test that catches the exception it just raised cannot see that contract at all.
MEDIUM — two mutation survivors, both the finding. A cap check hoisted out of the transport retry loop survived because every attempt inside that loop voids at zero: nothing the loop does moves the total, so the omission looks harmless. It is not — between a 429 and its backoff another process syncing the same KB can spend the headroom, and the retry would go out anyway. And the per-slice semantic budget survived because every test used a single slice, where "per slice" and "per document" are the same number; that was a defect I had found and fixed while writing the loop and then never put under tension. Lesson: a bound that is only ever exercised at N = 1 is not tested, it is agreed with.
MEDIUM — the module imported pypdfium2 at module scope, which §4.4 cannot afford. The
fingerprint path reaches this module on every query, on whatever install the user has, so a
top-level pdfium import made a coherence check on a claude-vision KB fail outright on a
core-only install. Caught by test_coherence_never_imports_a_paid_client — a test written in I2 for
a different reason, which happened to be the exact shape of this mistake. Recorded because it is
the second time this project has been saved by an import-graph test that nobody wrote for the case
that caught them.
LOW, worth keeping — the gate refused the commit, which is the gate working.
.paid-path-allowlist shipped empty at I7a specifically so that its first real entry would be
earned, and it was: the commit creating claude.py failed until the line was added. Its test then
turned out to assert only "0 exempt paths", which would have passed on any addition at all, so it
now pins the expected contents — widening an allowlist is how a gate like this one dies. Two dead
exception classes (TruncatedResponseError, RefusalError) were also removed: the stop_reason
branches replaced them and nothing ever raised either.
LOW, second pass — --estimate-only demanded an API key from a KB with nothing to estimate.
The transport was built before the walk, so a KB with no PDFs failed on a missing key instead of
reporting nothing. Built on the first PDF now. Also cleaned up in the same pass: an unreachable
except RequestTooLargeError around slice_pages (only build_request raises it, and by then the
call is committed — the size question belongs before anything is built), and a repeated
"claude-vision" literal where the module already imports the constant.
HIGH, fourth pass — the paid fingerprint omitted the model, so changing it reused another
model's text. The plan states the inputs as "(backend name, model id, prompt version, schema
version, request-shape version…)" and I wrote every one of those except the model. The cache key is
<content_hash>-<fingerprint>, so editing [extraction] model would have hit an entry a
different model wrote, with no miss, no warning and no stale marker — the §4.4 machinery intact
and looking at the wrong key. The plan names two tests for exactly this
(test_changing_the_model_misses_the_cache, test_changing_k_misses_the_cache) and I had written a
placeholder for the first that only asserted two unrelated fields were non-empty, which is how it
stayed invisible through three review passes and fifteen mutations. Fixing it meant threading
[extraction] model through the registry's FingerprintInputs contract — two real callers, sync
and the §4.4 coherence check — plus a test that the free backend's key is unperturbed by the new
parameter, or every existing free KB's index would have gone stale the day it was added. Lesson: a
test written to a name from the plan, but not to the claim behind the name, is worse than a missing
test — the plan's checklist reads as satisfied. The tell was there in the placeholder's own body:
it asserted things that could not fail.
MEDIUM, fifth pass — three of the plan's named tests had no implementation at all. Auditing
the plan's test list against the file (prompted by pass 4, since the same failure mode was in play)
found the multi-slice document, the oversize-slice split, and the no-floor-installed refusal all
missing — the first being where the slice-window arithmetic and the short final slice actually meet
a real PDF, and where an off-by-one either drops pages or sends the whole document to a paid API.
Writing them found nothing broken, which is the useful outcome to record: the code was right and
unwitnessed. Two of them also had to be sized from the fixture rather than from a constant — a
hard-coded byte threshold either never splits or recurses straight to the single-page failure, and
which one it does is a property of the corpus, not of the code. slice_windows and slice_bytes
lost their underscores in the process: they are the two things a reader most wants to check.
LOW, sixth pass — nothing drove pnk sync itself. Every test exercised a piece: the slice
loop, the ledger pairing, the cache's join key, the accountant's windows. The pieces were wired
together across four modules, and "each part works" is not the claim "the parts are connected" —
which is the seam an increment is most likely to get wrong. One end-to-end test now runs a real
pnk sync over a paid KB and checks the whole chain: the document indexed under claude-vision,
the ledger's call reconciled, and the cache entry carrying the operation_id/call_ids that §6.3
left null until this increment. It passed first time — but writing it introduced the pass's
one real defect, and only the [claude] leg saw it: the test swapped the registry entry and then
called unregister_extractor, which deletes. There is no undo for a name the package registers
at import, so two unrelated tests later in the session lost claude-vision entirely. Fixed with a
registered_entry accessor and a re-register in the finally. Lesson: a test that mutates
process-global state needs to restore the previous value, not remove its own — and a suite that
only ever runs on one CI leg will not show you which.
MEDIUM, seventh pass — the [light] leg's green was partly an artefact, and I had not really
run that leg at all. uv run --extra light does not prune extras a previous
--extra pdf --extra claude installed, so "all three CI legs pass" was one leg run three times. A
real uv sync --frozen --extra light — what CI actually does — then showed the paid suite failing
on its own while passing in a full run: tests/test_extract.py leaves a fake pypdfium2 in
sys.modules, and two --estimate-only tests were quietly relying on it. The underlying cause was
mine: _estimate_only imported page_count before the walk, so a KB with no PDFs demanded the
[pdf] extra to be told it had nothing to estimate — the same defect as pass 2's transport, one
line above where I had fixed it. Lesson: uv run --extra X is not uv sync --extra X; verifying a
matrix means reproducing what the matrix does, not asking for the same set three times. And a suite
that only ever runs after its neighbours cannot tell you what it depends on.
LOW, third pass — markdown emphasis reached a terminal. --estimate-only's help text carried
**A network call**, which argparse renders as literal asterisks: the emphasis was written for
CLI.md and pasted into a surface that has no renderer. Now checked against every command's
rendered --help output rather than argparse's internals — the artefact a user actually sees.
Backticks are deliberately allowed, since [extraction] backend reads fine in a terminal and is
the convention this CLI already used; flagging those too would have been a style crusade over
pre-existing text rather than a defect.
Also: stubs/anthropic.pyi joins stubs/pypdfium2.pyi, because the strict type gate runs on the
[light] leg where the package is absent. It records the one relationship easy to get wrong from
memory — APIConnectionError is a sibling of APIStatusError, and APITimeoutError a subclass
of the former — because checking them in the wrong order classifies every timeout as a plain
connection failure, which is the difference between recording €0 and admitting a possible charge.
18 mutations planted in total, 18 detected once the survivors got tests.
I7c — the completeness audit, staging, all-or-nothing (20260729 02:38)¶
MEDIUM — three of ten mutations survived, and all three were the same shape: a rule with no test
that it fires, only that it exists. Clearing staging after the complete entry is written had no
test that staging is ever cleared at all — and stale staging is not litter, because its key is
<content_hash>-<fingerprint>: a later run of the same document would find it, skip slices, and
serve text from a superseded extraction silently and for free. Stopping the corpus at the first cap
breach had no test that drove sync; the on_exceed tests build a SyncReport by hand, so they
can check what a stop means but never whether the loop stops. And keeping staging out of the
cache root had nothing asserting that survey, total_stats and clear_all cannot see it.
Lesson: the tests I wrote were about the rules I was thinking about while writing the code, which
is exactly the increment-shaped blind spot this project keeps rediscovering. Mutation found all
three in one pass because a mutation asks the question the test forgot: not "is the rule stated"
but "would anything notice if it stopped being true".
MEDIUM — the audit's own fixtures measured nothing. word_coverage tokenises on [a-zA-Z]+,
so forty words of f"{seed}{index}" collapse to one distinct word: every page scored 1.00, the
dropped-content test found no outlier, and the fixture could not have failed whatever the code did.
Caught only because that test failed for a different reason first. Lesson: a fixture built for a
metric has to survive that metric's own tokeniser — and "the test passes" would have hidden this
completely if the assertion had been slightly weaker.
MEDIUM, second pass — the audit made the very mistake it was written to avoid, one branch
later. A page above the yield floor whose text holds no significant words — a table of figures,
which is an ordinary PDF page, not an exotic one — gives word_coverage a denominator of zero. I
scored it 1.0: full preservation claimed for something never checked, and it drags the median
up, making the genuine outliers look less unusual. Six lines earlier the module argues at length
that a scanned page must be exempt rather than zero, for exactly the same reason in the other
direction. Now both are exempt. The fix then exposed a second worthless fixture:
_significant_words keeps only words of four characters or more, so prose("a")'s three-letter
words were never measurable either — that test had been passing purely on the 1.0 default it was
supposed to be testing around. prose now asserts its own output is measurable. Lesson: a default
that makes "unmeasurable" indistinguishable from "perfect" hides broken fixtures as effectively as
it hides broken code — and I wrote the argument against it into the docstring of the function that
did it.
LOW, second pass — one feature, two standards for the same question. _is_budget_refusal
identifies a refusal by exception type, with a comment saying an error string is prose and prose
gets reworded. Twelve lines later SyncReport.ok decided which failure was the budget's by
comparing that same prose. It now matches on the path, which is structural.
LOW — on_exceed had been parsed, validated and read by nothing since v0.1. A manifest key with
a choice() validator, a default, a template comment and a documented meaning, wired to no
behaviour at all. It now decides whether a budget stop is a failure. Recorded because validation is
what made it invisible: a key that round-trips through the parser looks implemented from every angle
except the one that matters.
LOW — the interruption I first scripted was not one the caller isolates. The test double raised
AssertionError when its script ran out, which sync does not catch, so it escaped the
per-document handler and failed the test rather than the document. Replaced with a timeout — a
real interruption, and one the caller actually handles. A test double's failure mode is part of the
test: if it fails in a way production cannot produce, the path under test never runs.
LOW, and the second time in one session — I composed a timestamp instead of reading one. This
entry's heading was written 03:04 moments after date had printed 02:38. CLAUDE.md's rule is
already explicit ("read the clock; never compose a timestamp"), and knowing the rule is evidently
not the same as running the command: the failure is that composing feels like recalling. Caught
both times only by diffing the two numbers on screen.
Also: another agent, working independently, found that I7b's own docs contradicted themselves —
STATUS.md said "claude-vision is a real extractor" in one row while the prose eight lines below
still explained that nothing can spend because it is a stub. I had updated the table and left the
paragraph justifying it. Seven review passes over I7b did not catch it. 15 mutations planted here,
15 detected once the survivors got tests.
The eval harness: three defects under one green suite (20260729 03:23)¶
Found while planning the links and graph releases, whose whole gate rests on this harness. All
three were live on main and all three passed every test.
HIGH — the multi-hop class measured nothing about hopping. Outcome.hops_followed was
computed for every scripted question and read by no metric — not recall_at_k, not by_kind,
nothing compare() looks at. Deleting the hop loop outright left by_kind["multi-hop"]
bit-identical. A multi-hop question was a single-shot search of its last hop's query wearing a
label. The one guard was assert any(outcome.hops_followed > 0 …) — an any() over five questions,
on a field that fed nothing.
HIGH — and that hid a defect in the golden set itself. Three of the five questions named their
last hop's document in expect; two named their first. So the scorer ran a query about
brittle-paper conservation and demanded the annual report. Nothing could catch the disagreement,
because hops fed no metric that could notice it. The fix makes expect exactly the union of the
hops' documents and asserts it for the committed set.
The numbers moved because the scorer was wrong, not because retrieval changed. recall@5
0.8788 → 0.9091, MRR 0.7737 → 0.8116, rerank precision 0.7273 → 0.7576, by_kind["multi-hop"]
0.80 → 1.00. Stricter scoring, higher score — because the two inverted questions had been asked
about the wrong document all along. A metric that improves when you make it stricter is telling
you it was measuring something else.
MEDIUM — compare() wrote by_kind into every baseline and never read it back. A change
lifting one class and dropping another by the same amount moves the aggregates by almost nothing;
CI was green through it. The question count had the same shape: written, never compared, so a
golden set that silently lost its hard questions would have scored better.
MEDIUM — the "cheap deterministic embedder" was not deterministic. HashingBackend hashed each
word with hash(), which Python randomises per process for str unless PYTHONHASHSEED is set —
and nothing sets it, nor can a conftest.py, since the value is read before the interpreter starts.
Which words collided in the 64-dimensional space changed run to run: one failure in 40 runs
before, zero in 60 after switching to zlib.crc32. It surfaced only because a newly written
test tripped over it once. A fake that cannot reproduce itself cannot tell a real regression from
its own noise (v0.1 rule 5).
The transferable lesson. All three survived because the tests asserted that the machinery ran,
never that it could detect. The mutation pass is what caught them: four mutants — hit ignoring
hops, the by_kind comparison, the question-count check, and the golden-set consistency assertion —
were introduced deliberately and all four killed a named test. Green proves the tests ran; only
breaking the code on purpose proves they can see.
Shared-file contention tooling (20260729 04:06)¶
HIGH — git status --porcelain's leading space is significant, and a helper doing .strip() on
the whole output silently ate it. The overlap gate's git() wrapper returned
proc.stdout.strip(), which is correct for merge-base and symbolic-ref and wrong for
status --porcelain: a modified file is reported as M CHANGELOG.md with the status in columns
0–1, so stripping the output removed the first line's leading space and the path parsed one
character short — HANGELOG.md. It matched nothing, and the gate reported "no overlap" with total
confidence. Exactly the one failure a contention gate cannot have.
Two things about how it was caught, both worth keeping:
- The tests drive real
gitagainst real temp repositories, not a mockedsubprocess. The gate is almost entirely a set of claims about git's behaviour — whatdiff A...Bmeans, which commitmerge-basepicks, howstatus --porcelainspells a rename — and a mock asserts the author's belief about each of those rather than the behaviour. A mocked test would have returned" M CHANGELOG.md"from a fake and passed with the bug present. - The mutation pass re-introduced this exact bug deliberately and confirmed the right test
fails.
git()is now documented as trailing-newlines-only, with the reason, because the next person to "tidy" it back to.strip()will find nothing obviously wrong.
MEDIUM — a clean auto-merge is not a correct merge, and only the loud half of that was being
managed. Three parallel branches edited CHANGELOG.md, docs/STATUS.md and docs/DESIGN.md
inside one hour on 20260729. CHANGELOG.md conflicted and was resolved by hand; the other two
merged silently, because the edits landed on different lines. Git merges edits that do not
overlap textually, never edits that agree — so two agents can leave one document contradicting
itself with every command reporting success, and no conflict resolution however careful would
surface it.
The response is deliberately in two layers, because one does not cover the other's cases:
changelog.d/andretro.d/remove the cause for the two documents every change must write to — separate files cannot conflict, so for those the class stops existing.tools/shared_file_overlap.pyreports what remains, which is the living documents (docs/STATUS.md,docs/DESIGN.md) that fragments do not suit, because they are edited in place rather than appended to.
MEDIUM — splicing produced two ### Added headings under one ## [Unreleased], and only
cutting a release revealed it. The tool inserted each rendered ### Category block under the
anchor without looking at what was already there, so a section that already carried an ### Added
from unmigrated prose ended up with two. Keep a Changelog expects one heading per category, and a
reader scanning for "what was added" stops at the first. Fixed by merging into an existing heading
when there is one, bounded to the anchor's own section so a shipped release's ### Added is never
written into.
Worth keeping for the reason it was missed: test_apply_leaves_existing_unreleased_prose_exactly_
where_it_was was written deliberately, and it passed — leaving existing prose alone is correct. The
case it did not imagine is that the existing prose has its own category headings. A test written
by the reasoning that wrote the code inherits its assumptions, which is the same increment-shaped
blind spot CLAUDE.md already names; here the escape was dogfooding, not mutation testing, because
the mutation pass only ever perturbs cases somebody already thought of.
Separately, and pre-existing: [Unreleased] had accumulated seven category headings by hand
over several days — two ### Added, three ### Changed, two ### Fixed. Consolidated when cutting
0.3.0, with a check that every non-heading line survived the regrouping.
LOW — the fragment tool takes --repo so its tests can drive the real artifact. Importing a
tools/ script from a test needs sys.path surgery that pyright and ty then cannot resolve —
ty failed the build on exactly that. Running it as a subprocess follows the precedent
tests/test_paid_path.py set for tools/paid_path_gate.py, and tests the same artifact check.sh
runs, argument parsing included.
I8 — Page citations on both surfaces, and pnk doctor's text yield (20260729 04:55)¶
HIGH — pinakes_get on a PDF crashed, and no test could have caught it. document() read the
source with read_text(encoding="utf-8") inside a try guarded by except OSError.
UnicodeDecodeError is a ValueError, so the guard never applied and the traceback escaped through
the MCP surface. It survived since v0.1 because no test ever called pinakes_get on a PDF — the
serve suite's KB is two markdown files, and every PDF test lives in a module that never builds a
server. A gap between two test modules is invisible to both.
HIGH — the plan's page_start == p assertion is wrong, and would have failed on correct code.
The I8 draft specifies that every chunk covering the traced offset "reports page_start == p". A
chunk that straddles a page break starts on the earlier page, so a word on the later one
legitimately sits inside a chunk whose page_start is smaller — which I5 explicitly allows and
which the citation renders as p1-2. The trace asserts page_start <= p <= page_end instead. The
draft had already corrected "exactly one chunk" to "at least one" for the same fixture; the page
assertion needed the same correction and did not get it.
MEDIUM — the stale_extraction row understated its own gap by half. DESIGN §4.7's pending
amendment said the marker "today reaches the CLI's Passage but stops there", so I8 would carry it
to the agent surface. It reached the CLI's Passage object and was then dropped by the CLI
renderer too — computed in search.py, surfaced nowhere. A field that exists in a dataclass reads,
at review time, like a field that is displayed.
MEDIUM — the free per-page yield lived inside the only module allowed to import anthropic.
survey_free_yield measures what pypdfium2 got out of a page; nothing about it is paid. But it
sat in extract/claude.py, so pnk doctor — a free command — could not consume it without
importing the paid path to ask a free question, against CLAUDE.md's own "never probe a backend by
loading it". Moved to extract/pageyield.py. The alternative, a second per-page loop in doctor.py,
would have been a second definition of a measurement that decides whether to spend money.
A dead statistic in a shipped template. The notes template's [budget] comment told every new
KB that "no shipped code path spends money" — written when that was true, still shipping three
releases later. docs/GUIDE.md said the paid extractor was "built but in no release yet". Both are
the same failure as the four README claims found at 0.1.2: prose drifts toward the design, because
the design is what you are thinking about while writing it. Neither was in the increment's scope;
both were found by reading the files the increment touched for other reasons.
Mutation testing found the test whose name was stronger than its assertion. Twelve of thirteen
mutations were detected. The survivor deleted pnk doctor's unmeasured-document tally, and
test_a_swept_cache_entry_is_counted_as_unmeasured_rather_than_as_a_pass stayed green — because
that test sweeps the whole cache and reads a branch that counts documents rather than the tally.
The mixed case, where some documents measure and others do not, is the one the tally exists for, and
it had no test. Its name claimed the general property; its body tested the degenerate one.
The review pass over I8's own diff¶
Three defects, all in pnk doctor's new check, all found by reading it adversarially rather than
by any test:
HIGH — the health check crashed on an unhealthy KB. is_paid_backend raises
BackendUnknownError on a name it does not recognise, and the check passed it every PDF's recorded
backend. A KB indexed by a newer Pinakes, or with an extra since uninstalled, would make pnk
doctor itself raise — the one command someone runs because their KB is in a state they do not
understand. §4.4's coherence check has carried the identical guard, with the identical comment,
since I5; the new code was written beside it and did not copy it.
MEDIUM — a KB whose PDFs are all paid-extracted got a permanent, unclearable warning, with a
remedy (pnk sync) that on those documents spends money. The check deliberately skips
paid-extracted documents, then reported the resulting empty measurement through the branch meant
for a swept cache. Skipped-on-purpose and lost look identical to a counter.
LOW — a single out-of-range page bound was reported as a backwards range. page_start=5 on a
two-page document read "pages 5-2 is not a range within it", because the bounds were validated
after the omitted one was defaulted. It describes a range the caller never asked for, and reads as
Pinakes' mistake rather than a bad argument. Found by running the tool, not by reading it.
What the tests could not have caught. All three needed either a KB state no fixture builds (an unknown backend name, wholly paid extraction) or a human reading an error message. The increment's own tests were green throughout, and so was a sixteen-mutation pass — mutation only perturbs cases somebody already thought of, which is the same limit that let the fragment tooling ship a duplicate-heading bug at the 0.3.0 release.
I9 — Auditing the verification table (20260729 05:40)¶
HIGH — the table that verifies everything verified nothing. plans/20260727_1543-v0.2.md ends with 98 rows,
each promising a property and naming the test that holds it, under a preamble reading "a promise in
a section with no owner is a wish". 61 of the 98 test paths did not resolve. Not because the
properties went untested — nearly all are tested, usually under a better name than the plan guessed
— but because the paths were written before the tests existed and implementation renamed them.
The failure is not the renaming. It is that nothing ever read the table, so it could drift a row
at a time for nine increments with every gate green. A table of test paths is prose until something
executes it, and prose about tests reads exactly like tests. The fix is not a better table: it is
tests/test_verification.py, which resolves every reference in docs/VERIFICATION.md and fails on
the first one that does not exist. The document can now go stale exactly once — in the commit that
breaks it.
The audit found a real gap on its first run, which is the argument for doing it.
test_every_v02_check_appears was assigned to I8, named in the table, and never written. Writing it
(as test_every_doctor_check_is_exercised_by_a_test) immediately found five pnk doctor checks
with no test at all: template, reranker, model cache, extensions, links. Link coverage is
a §6.2 promise; the reranker check exists so a health check does not download weights. Both had
shipped untested since I11.
MEDIUM — I wrote the exact CI assertion the plan warned against, and only running it caught it.
The plan says the core-only wheel smoke must use "a core-only KB that does not need embeddings,
because today's smoke KB fails on sentence-transformers long before it reaches an extractor, so the
assertion would prove nothing". I built a PDF-only KB believing that satisfied it, and it does not:
the embedding backend loads before any extractor, so pnk sync on a PDF-only core install still
fails on pinakes[st]. My grep -q 'pinakes\[' passed — against the wrong extra. pnk doctor is
the only surface that reaches the extractor question on a core-only install, because it reports a
failing backend as a check and carries on. Reading the plan's warning was not enough to avoid the
thing the plan warned about; running the command was.
A plan is a historical record, and correcting it would have destroyed the evidence. The
temptation was to fix the 61 paths in place. That would have erased the only proof that predicted
test names drift — and with it the reason tests/test_verification.py needs to exist. The plan
keeps its predictions under a dated supersession note; the resolved mapping lives in docs/.
A sidecar that would not parse was replaced by a freshly minted one (20260729 07:26)¶
HIGH — the one failure the design says is unrecoverable, shipped since v0.1 and live in 0.4.0 on
PyPI. walk_sources dropped a sidecar it could not read (except PinakesError: continue,
sync.py:385) so that one bad file would not stop the walk. That was right. What it did not
account for is that the document then matches DESIGN §6.4's "new path, no sidecar" row — and the
mint path wrote a freshly minted sidecar over the file still holding the document's permanent ULID.
Every inbound pnk:// link points at the id that was destroyed, and there is no migration
machinery by design.
Three things made it invisible:
pnk syncreported success.report.okwas true,failuresempty,1 indexed.pnk doctorafterwards reportedsidecars: N readable,duplicate ids: none,failures: none— every check green, because the unparseable file no longer existed. The skip site's own comment said "reported bypnk doctor"; that safety net could never fire, because syncing repairs the symptom by destroying the evidence.- The module that owns the risk had already named it.
sidecar.write's atomic-rename comment calls ULID loss "the one failure in this module that no later command could repair" — and then handed the file to a caller that overwrote it deliberately. A guard written against a torn write says nothing about a deliberate one.
How it was found, and what that says. Not by a test — by hand-authoring L1's partner corpus
with one deliberately unresolvable link, syncing it, and noticing that pnk doctor reported 10
links where the density gate had just counted 13. The discrepancy was three links, all on one
document, and that document's sidecar had a new ULID and a created stamp from the sync. A
second, independent count of the same population is what exposed it; every check that read only
the post-sync state agreed with itself. L7 requires the gate's number and doctor's number to be
the same population for a different reason — so that a user and CI cannot disagree — and this is
the argument for computing both at all.
The fix, and one guard that was removed for failing its own mutation test. Minting goes through
a new sidecar.create, which refuses where a file exists; the refusal lives at the write rather
than in the caller, because "the only caller that reaches it" is a property of today's code. A
matching guard added to the --index-only branch of _mint proved undetectable by mutation —
deleting it changed no observable behaviour, only which of two SidecarErrors was reported,
because the indexing path re-reads the sidecar for its metadata and that read refuses first. It
was removed rather than kept, and _mint's docstring records why, so a later reader does not
"restore the missing check". A guard that cannot be mutated is not a guard; keeping it would have
been the kind of decoration this project's mutation step exists to catch.
The adversarial pass found the bigger half. The fix as first written covered only the case
where the document is absent from the index — a fresh KB, a fresh clone, a --rebuild. For a
document already indexed whose content is unchanged, pairing yields RefreshMetadata, and that
branch sits outside _apply's per-document try, so _refresh_metadata's re-read of the
sidecar raised straight through _apply, the action loop and sync(). One hand-broken file aborted
the entire corpus: no failures row, no set_meta, no commit, and every document after it
unprocessed — contradicting this module's own opening promise and docs/CLI.md's "failures are
recorded, the run continues". That is the likeliest route in: edit a link by hand, re-sync. Three
paths existed for one cause (Mint, Reembed, RefreshMetadata) and each behaved differently;
they now report identically. The lesson is about where the first fix stopped: it was written
against the reproduction, and the reproduction was a fresh KB because that is what a corpus author
happens to have. A fix aimed at a repro covers the repro's path.
Two smaller things the same pass caught, both about honesty rather than correctness. The refusal
said only "already exists, so a freshly minted sidecar cannot be written over it" — which reads like
a Pinakes bug (of course it exists) and says nothing about the character the user mistyped, while
DESIGN, the changelog and the commit message all claimed it named the parse error. The walk has to
swallow that error to keep walking, so the mint path now re-reads the one file to recover it. And
the remedy said "repair the file rather than deleting it — it holds the permanent ULID", which is
false for the second shape the tests deliberately parametrise over: id: not-a-ulid has no ULID to
repair to, and a user in a blocked pre-commit was being told not to do the only thing that
unblocks them.
What the tests are parametrised over, and why. Two unrelated parse failures — a malformed link
URI and a malformed id. The defect is any PinakesError from read_sidecar reaching the mint
path, and a test written only against a bad link would have gone quiet the moment link parsing
moved.
L1 — The partner corpus and the density gate (20260729 08:47)¶
HIGH — the gate did not gate the one shape it was built for. degrees was keyed by basename
(path.name), so two documents sharing a filename in different folders collapsed to one key and the
later-sorted one overwrote the earlier. Demonstrated against the shipped gate: a degree-6
hub — 50% above the cap of 4 — behind docs/aaa/policy.md exited 0 and was reported as "worst
degree 1 (policy.md)". Density alone permits one hub wired to everything; the degree cap exists
separately precisely to catch that, and a basename key is the single way it cannot. Now keyed by
path relative to the KB root. The committed corpora are flat, so nothing in this repo would ever
have exercised it — the fixture had to be built to find it.
HIGH — the gate counted sidecars where it meant documents, and was wrong in both directions.
An orphaned sidecar (which pnk sync deliberately keeps) inflated the denominator: 8 of 10 real
documents linked read as 27% and passed a 35% cap. A document whose sidecar had not been minted was
invisible, so the gate reported nonsense on any KB where sync had not run. Documents now come from
[sources] include, which is what the word means.
HIGH — two documents kept a privacy claim the increment made false. README.md still said
"The sole KB here is a small synthetic corpus" and CLAUDE.md "The only KB here is the synthetic
demo corpus", while DESIGN.md was updated in the same commit to "the two synthetic corpora". The
repo contradicted itself about what had been committed, in the section a reader consults because
they are worried about exactly that. The audit-the-neighbourhood rule exists for this and I applied
it to DESIGN alone — the file I was already editing — which is the failure mode the rule describes.
A claim that was true by coincidence, stated as if by construction. The gate's docstring,
check.sh and the changelog all said it "counts the same population pnk doctor reports". The
link counts are the same population, by construction. The document counts are not: doctor counts
indexed documents, the gate counts files matching include. They agree on the committed corpora and
nothing makes them. The wording now says which half is guaranteed.
Two gates and no test that either still exists. L1 added a check.sh gate and a CI job and
asserted neither, so deleting either left the suite green — in a repo that already has
test_check_sh_declares_the_pdf_quality_guard, written for that exact failure. The convention was
there; I did not apply it. Both are pinned now, and CI's negative step additionally greps for the
message rather than accepting any non-zero exit, since a crash, a missing corpus, or uv itself
falling over all satisfy the weaker check.
What the corpus taught about relations. counterpart was used both as a reciprocated 1:1
pairing (inward loan ↔ outward loan) and as a loose association (courier requirements → outward
loan). A later increment reading counterpart as a pairing would be misinformed by its own fixture.
Now governs.
The self-form fixture is not a fixed point of the product's own writer. sidecar.write
resolves self to a ULID on write, so anything that reads and rewrites that file destroys the trap
L2 needs — and pnk link (L6) writes exactly that key. The test catches it, but a long way from the
cause, so the hazard is named in the test rather than left to be rediscovered.
Mutation, twice. Seven targets before the review, seven after the gate was rewritten. Two
mutations appeared to survive and were worth more than the ones that failed: the first had not
applied at all — a str.replace searching for 'self' where the source says "self", the exact
no-op conftest._rewrite refuses, met in my own mutation harness, which now asserts the
substitution happened. The second was real: nothing asserted that the report prints the cap in
force rather than the module default, so --max-density 0.1 printed "27% of the 35% cap" and then
failed the corpus in the next line.
And the verification gate caught its own author. Renaming
test_the_committed_split_is_what_pnk_doctor_counts (misnamed — it never consulted pnk doctor)
turned tests/test_verification.py red until docs/VERIFICATION.md was updated with it. That is
I9 working on the first increment after it shipped.
L2 — Reverse-scan (20260730 16:51)¶
One root cause behind all three HIGH findings: I bypassed manifest.load and kept none of what
it was doing. The bypass is right — a partner may run a newer Pinakes whose manifest mentions keys
this one has never heard of, and refusing to read a neighbour's inbound links over that would make
every connected KB a version dependency of every other. But load is not only a parser; it is also
the place that rejects an absolute [sources] roots, rejects .. in one, and validates the include
patterns. Reading the TOML directly removed all of it and replaced none of it, and the partner's
manifest is input this KB does not control. Every one of the three failures below is that same
sentence.
A partner renaming its own docs/ silently deleted every inbound row it had. A roots entry
that is not a directory was a quiet continue, so the walk yielded zero sidecars, reported
complete=True, and the caller did exactly what it is written to do with a complete walk: delete
and replace. Reproduced — rows 1 → 0, link_scan empty, last_scan stamped fresh, so the retry was
suppressed for a full window too. This is precisely the mass deletion the complete flag exists to
prevent, arriving through the one door the flag was not watching, and no "successful walk" test
could ever have caught it because they all leave the partner's sidecars where they are. A missing
root is now a walk failure with a reason.
The partner's exclude was ignored, while a comment claimed otherwise. sidecars_under's
docstring said a document "whose document was excluded" contributes nothing; it read only roots
and include. The shipped notes template stamps exclude = ["**/drafts/**"], so this is not an
exotic configuration — it is the shape of every KB pnk init creates, and the scan was recording
inbound links from documents the partner's own KB does not contain.
A partner's manifest could crash pnk sync on a git hook. The sidecars_under call sat
outside the try, and Path.glob raises on patterns manifest.load would have rejected —
NotImplementedError for a non-relative pattern, ValueError for an empty one. Both escaped
sync() entirely. The module's central promise is "nothing here raises", precisely so a partner
that is merely broken cannot block a commit; the one call that could raise was the one left outside.
Two tests that could not fail, both of mine. test_the_partner_is_never_locked asserted the
partner had no .pinakes/ — on a fixture where the partner was never synced, so the directory had
never existed. It proved nothing was created and nothing whatever about locking; it now holds the
partner's real SyncLock while the local sync runs. And a test asserting no SQLite connection was
left open re-asserted pre-existing sync() behaviour (_run's finally: close() always releases
it), so no L2-shaped defect could have made it fail. Deleted rather than kept: a test that cannot
fail is worse than no test, because it is counted.
A failed local run blamed the partner. known_documents is read from the index, so a document
that failed to index this run is absent from it — and a genuine inbound link was then reported as
pointing at a document this KB does not have. It does have it; it failed to index it. The local
picture is now passed as None on a failed or budget-stopped run, which suppresses that check
without touching the rows, since the rows come from the partner and owe nothing to our state.
_run already guards active_content_hashes on report.ok for the same class of reason — the
precedent was there.
Dead code that credited itself with someone else's work. ScanResult.delisted and the
known_kb_ids parameter were computed every sync, complete with a docstring explaining that the
rows "are removed" — by a function that never read either. The sweep is store.forget_reverse_links,
which takes the manifest's ids directly. Removed, along with the SELECT DISTINCT that fed it.
Mutation: 11 targets before the review, 5 more after, all detected. The one apparent survivor
was equivalent code rather than a gap — taking src_kb_id from the declared id instead of the
partner's own is indistinguishable wherever a row is written, because the mismatch guard refuses
first. The guard is what carries the weight, so the test asserts what makes the assignment moot: a
mismatched id writes no rows and no kb_refs entry.
And a test premise of mine was wrong, which the failure said plainly. _replace_links only runs
for a document that gets an action, so the reverse-then-authored ordering needs the document to
actually change — a second sync skips everything and rewrites nothing. Worth keeping because it is a
fact about when authored links are re-asserted at all, not just about this test.
L3–L4 — The traversal core and pnk links (20260730 18:06)¶
Four HIGH findings, all in the properties the increment's own prose claimed loudest. That is the pattern worth keeping: the module docstring argued at length for double-capping, precedence and server-side clamping, and each of those three was where the defect was. Writing the argument down appears to have substituted for checking it.
The response was half-capped. max_rows and token_budget gated neighbours; the frontier
was appended to unconditionally. Measured: a caller asking for one row received 1,000
frontier entries — and the frontier is the part an agent parses to decide what to ask next. Now
capped, and ordered so that entries about nodes you did not get come first: capping without that
ordering let the depth notes of accepted nodes fill the whole budget and crowd out every rows
note, so a caller asking for 2 of 5 was told nothing about the 3 it missed.
"Every bound is clamped server-side" was true of two of the four. max_rows=10**9 returned
3,660 rows with an empty truncated. Three documents said otherwise. Once this is reachable over
MCP the caller supplying max_rows is the untrusted party, so the sentence was not merely
inaccurate.
A frontier entry contradicted the answer beside it. A node dropped by fan-out at one hop and
reached at another kept its fanout entry — while sitting in neighbours and having been
expanded. FrontierEntry's own docstring says "discovered and not expanded". Stale drops are
now retracted at return; terminal and depth are kept, because those describe accepted nodes
deliberately not expanded, which is the contract rather than a contradiction of it.
Half the stated precedence was inverted. The row and token checks ran before terminality was
consulted, so a terminal neighbour dropped by the row cap reported rows — inviting a retry with a
smaller request, which cannot help. Of the ten pairs the declared order implies, five were
backwards and exactly one was tested: the one the code happened to honour.
The gate had three separate ways of being vacuous, and its docstring was an essay about gates that cannot fail.
- It passed against a
traverse()that returned an emptyResult— every check was one-sided, so zero neighbours satisfied all of them. Now equalities. - It imported
MAX_DEPTHandMAX_ADJACENT_Kfrom the code it gates and compared them with themselves. Raising the caps to 10 and 150 moved the walk and the gate still passed, whiledocs/MANIFEST.mdwent on promising 64. The documented numbers are now literals in the gate — a second copy, which is the only thing that makes a silent change show up. - It had no negative check, in a repo where the immediately preceding increment added one to its
sibling job and a test that guards it. Added, with a
--expect-depthoverride so CI can drive the gate into failure on purpose and assert the stated reason.
Two more the same pass found. The row cap truncated by parent-expansion order while ranking was per-parent, so a top-ranked neighbour behind a low-ranked parent lost to a worthless one in front of it — the same mistake as truncate-then-rank, one level up. And node-level row dedup silently dropped a second distinct relation to the same target, in a module whose contract is that a fact about the graph is returned rather than dropped; rows are now deduped per edge while expansion stays per node.
A dead sort term with a docstring defending it. _rank sorted by (-weight, distance,
node_key) and explained that a nearer neighbour of equal weight ranks higher. _rank is called
with one hop's candidates, so distance was constant in every sort. Removing it changed nothing —
which is how it was found, and is the argument for deleting rather than believing prose.
A test that could not hold its name. test_depth_counts_logical_hops_not_physical_edges had no
hub in its fixture and its own docstring conceded the core never sees one; it was a second copy of
the clamp test wearing a larger claim, and docs/VERIFICATION.md cited it for a promise it could
not carry. Renamed to what it actually checks. The logical-hop promise belongs to the provider that
composes hubs.
And new behaviour shipped without tests. [retrieval] adjacent_k and _toml.integer(maximum=)
had none — the commit message's claim that a value above the cap is refused rather than clamped
was asserted and never executed, against this project's own rule that tests ship in the increment
that introduces the behaviour.
A process failure worth recording separately. L4 was built in L3's worktree while L3's
adversarial review was still reading it, so the reviewer found the tree dirty with a parallel
increment's work and had to run every probe against a copy. It cost the review nothing this time
because L4 added files rather than editing traverse.py, but that was luck. One increment, one
worktree, and the review finishes before the next one starts.
Four silent str.replace no-ops this session, one of which spliced a new import into the middle
of an existing one and produced a nonsense symbol. It is the same failure conftest._rewrite exists
to refuse, met in editing rather than in a fixture. Non-trivial edits now go through a tool that
errors when its anchor does not match.
L5 — pinakes_links on the MCP surface (20260731 11:29)¶
The defect was in the field nobody thought to assert¶
L5's own mutation pass killed all three of the targets the plan named. An adversarial review then mutated eight more payload fields and watched every one survive the full 887-test suite. Two of those were real defects, not merely untested:
directionwas keyed by node, while a row is(node, rel). Givena --related--> bandb --cites--> a, asking aboutareported the citation as running froma— the opposite of what someone wrote. Shipped in L4, copied verbatim into L5, wrong on both surfaces. The provider's own docstring argued the case for the key it used: "Keyed by node, because a node reached both ways is still one neighbour" — true of the node, irrelevant to the row.DIRECTIONSwas defined and never enforced.edges_oftestsin ("out", "both")andin ("in", "both"), sodirection="outbound"ran neither query and returned a confident empty answer with a "no links from here" hint.argparsechoicescovered the CLI; the MCP surface, the one an untrusted model types into, had nothing.
The lesson that generalises: a field with no assertion is a field that can be a constant. Ask of
each one, "which mutation would this catch?" — not "is it correct?". scored_by_query, the field
L3's docstring calls load-bearing, could be frozen to True; unresolved, whose contract says
"returned, never dropped", could be frozen to [].
A tidy fixture defeats a mutation test. Three fields survived even after tests were written for
them, because the KB-backed fixtures were too clean: the fake embedding backend's vectors are
orthonormal, so every cosine is exactly 1.0 and deleting round() changes nothing; nothing hit a
response cap, so truncated could be frozen empty; no frontier entry sat past distance 1. The fix
was to build the dataclasses directly and take the fixture out of the question.
Two copies of one payload had already drifted — the MCP frontier carried a distance the
CLI's did not, scored_by_query reached only one of them, unresolved dropped a kb_id its
sibling lists carried. Neither failed, because nothing compared them. They now share
pinakes.graph.present, and a test asserts both surfaces project the same keys.
Calling a tool is not the same as exercising it. The free-path gate was strengthened to invoke
pinakes_links rather than only list it — but the fixture KB had one document and no links, so the
whole neighbour projection never executed. A raise SystemExit planted in that loop never fired.
The fixture now authors one intra-KB and one unreachable-KB link, and the same probe fires.
The fix for a wrong answer produced a differently wrong answer, and the tests written with it
could not see that either. Keying directions by (node, rel) was right; merging to both
across expansions was not. directions accumulates over the whole walk, so an edge discovered
while expanding an unrelated parent rewrote a row already emitted from the start — and a row's
direction then changed with --depth, to exactly the untruth the fix was written to remove. Both
new direction tests ran at depth=1, where the start is the only parent, so neither could reach it.
A second adversarial pass found it by varying the one parameter the tests held fixed.
The generalisation: when a fix adds a rule, test the axis the rule is defined over. The rule was about which expansion a direction came from, and every test pinned a single expansion.
A third pass found no new defect in the traversal itself, and four in what surrounded it. The
(node, rel) scheme was probed against a reciprocal pair, a mutual same-rel pair, each direction,
a self-loop, a 3-cycle, a node reached at two different hops by different relations, a node reached
by two parents in one hop with opposite directions, and a node dropped by fan-out then re-reached —
all correct. What was still wrong sat one layer out: an assignment nobody asserted, a message worded
from the wrong end, a branch ordered ahead of a better one, and an assertion satisfied by a
substring.
Two of those are worth naming as patterns:
assert "-> related: b" in outputpassed on<-> related: b. A substring assertion over rendered text will match a longer glyph containing the shorter one, so dropping the outbound arrow entirely left the test green. Match whole lines when asserting on human output.- Splitting
f(x, scores=s)intof(x); f.scores = smoved a value out of the type checker's reach. The construction was covered by the tests that built providers directly; the assignment was covered by nothing, and deleting it disabled query ranking with every gate green. When a refactor turns an argument into a mutation, the mutation needs its own assertion at its own call site — and there were two call sites.
Left for the graph release (L3 core, predating this increment, found while probing): a node
dropped by fan-out at hop 1 and re-reached at hop 2 is emitted with distance: 2 although it is one
authored hop from the start; and a self-loop (a --sameas--> a) is dropped entirely — not a
neighbour, not unresolved, not on the frontier.
A fix applied to one surface is half a fix. Round 3 gave pinakes_links the rule that a
narrowed walk reports the narrowing before it reports dangling links — and left pnk links branching
on unresolved alone, in the same commit, so the CLI told a user their links "resolve to nothing"
about a document with a live neighbour one dropped --rel away. Both the docs and the changelog
described the MCP behaviour as though it were both. The two surfaces now share
present.is_filtered and present.arrow, which is the only way this stops recurring: the rule has
to live in one place, not be applied twice.
A remedy in an error message is a claim, and it was false. The dangling-links hint sent the
caller to pnk doctor — but doctor._links inspects only the destination side of local sidecar
rows, so when the missing endpoint is the link's source (a deleted document whose outbound rows
survive the soft delete) doctor reports links: OK and contradicts the message that sent you there.
Dropped the clause; extending that check belongs to L7, which owns doctor's link coverage.
Four review rounds, each finding real defects in the previous round's fix, then converging: 11 findings, then 11 with one HIGH, then 7 with none, then 5 with none. What the last two rounds found was never the traversal — it was the layer around it: an assignment nobody asserted, an assertion satisfied by a substring, a message worded from the wrong end, a branch ordered ahead of a better one, and a rule applied to one of two surfaces.
The rule two rounds were spent getting right had no test that could detect its inversion. Round
5 found the shipped behaviour correct on both surfaces and the precedence — filter before dangling
before "no links" — freely reversible with the suite green. The cause was a fixture that could not
make both conditions true at once: --rel narrows provider.unresolved as well as the neighbours
(edges_of receives the same rel), so a rel-filtered call leaves unresolved empty and the
branch being out-ranked never competes. --direction is the lever that does it — an outbound link
that dangles and an inbound one that is live. The assertion that named the defect in its own message
("one dropped argument away from a live neighbour") was the vacuous one.
Test the discriminating case, not the two sides separately. A precedence rule is only observable where both branches are eligible; a fixture that satisfies one at a time asserts the wording of each and the order of neither.
L5b — ruamel.yaml replaces pyyaml in the sidecar (20260731 11:29)¶
Swapping a YAML library is not a swap¶
The plan predicted three failures precisely, and all three landed as written — which is worth
recording because it is the first increment in this project where that happened. It named the 872nd
test ({id: x, : }, which ruamel parses, so the case fell through to the id check and the
parse-error branch had been asserting nothing), the free-path gate being red on day one, and the
ScalarBoolean coercion being insufficient at one level.
The free-path gate was defeated by its own harness, and I wrote the defect. _author_links in
tests/free_path_run.py — added in L5 to close a coverage hole — wrote a sidecar through
yaml.safe_dump. That put yaml into the very module list the gate inspects, and it was also the
last PyYAML sidecar writer in the repo: a fixture written by one library and read by another,
which is exactly the divergence the gate exists to forbid.
existing[:] = keep wipes ruamel's comment metadata outright. Reconciling a sequence by
rebuilding a keep-list destroys CommentedSeq.ca.items entirely — every comment in the block, not
just the removed entry's — while del existing[index] shifts the survivors. Measured: {} against
{0: '# first', 1: '# third'}. Both merge functions had it.
A comment before a sequence entry belongs to the entry above it, exactly as it does for a
mapping key. The plan pins the deletion limitation for mapping keys; it is broader than that. After
deleting the middle of three commented links, # first stays correct, # second reattaches to the
third link, and # third disappears. The surviving links are all correct; the prose beside one of
them is not.
"Unobservable" was the wrong conclusion; "observable only where something else is broken" was the right one. The plan's "assign a known key only when its value actually changed" looked untestable — every known-key value is the node read out of the document and written straight back, so a write of an unchanged document is already a no-op. Two attempts at a test passed against the mutated source and I wrote that no mutation of it could fail. A reviewer then removed the rule and the committed corpora stopped round-tripping: the short-circuit was masking the duplicate-link defect below, not proving its own redundancy. Once that was fixed the rule really was unobservable — but the claim was true by accident for two commits, and the difference is exactly what an adversarial pass is for.
-x makes a mutation look like it was caught by the wrong test. Two links mutations appeared to
be killed only by an unrelated pre-existing test; without -x both were also killed by the test
written for them. Run the mutation pass without early exit, or the report is about test ordering.
A merge key must be the identity the storage layer already uses. Reconciling links on to
alone looked sufficient and is undefined the moment two links point at one document with different
relations — which _links() accepts and the index stores as two rows, its primary key including
rel. Measured on the version that had it: dropping an unrelated third link rewrote the first
link's rel to the second's and deleted the second, leaving one row carrying the wrong relation
under the other's comment. The index's own PRIMARY KEY was the answer, and it was already written
down in store.py.
A recursive rule needs a depth bound as much as a base case. "A key absent from the new mapping
is deleted" is required at the top of provenance — or --force leaves a false paid claim behind —
and destructive one level down, where with_extraction_provenance builds a plain four-key
replacement and the user's own reviewed_by sits beside content_hash. One sentence, two opposite
correct answers, distinguished only by depth.
The exit criterion was the thing nobody ran. The plan's one falsifiable sentence — every
committed sidecar still round-trips — had no test, and running it by hand found a pnk://self/…
entry in partner-kb being deleted, rebuilt without its unknown per-link keys, and moved to the end
of its block. _links() expands self on read, so the loaded entry's raw to never equals
anything in the reconciliation set. The docs bounded the invariant with "pnk://self/… expansion",
which reads as the URI text changes and not the entry is rebuilt — a documented exclusion that
quietly covered a defect.
Quoting was applied on the path that was tested and not on the path that ships. Decision 23's
predicate reached the merge branch and the mint, but not the branch taken when a key first
appears — which is the branch pnk link will follow on a sidecar that has no links: yet, i.e.
almost all of them. Three quoting mutations survived the whole suite.
An error message is part of the interface. Three of this increment's breaking changes surfaced
as TaggedScalar, ScalarFloat and OctalInt — ruamel class names, from a library the user never
chose, with no remedy. The type is not what they need; "quote it, or drop the tag" is.
A fix instruction can carry its own defects, and two of pass 6's did. Keying links on the
(to, rel) pair made the pair the entire content of an entry, so no matched entry was ever
updated and every rel edit became a delete plus an append — landing straight in the comment
misattribution the rule was written to avoid. And "positional fallback among equal pairs" was too
vague to implement; what I wrote from it used a set, which collapses two identical entries: three
links in, one out. The final rule needed three explicit clauses — resolve before comparing,
multiplicity never a set, assign rel in place — each naming the shipped version that got it wrong.
"Exactly the call being protected" was true of the call and false of the argument. The
JSON-encodability check ran json.dumps(extra, sort_keys=True, ensure_ascii=False) — the same
function store.dumps_metadata calls — over extra alone. _metadata() hands that function
{"tags": …, "provenance": …, **extra}. A uniformly int-keyed {1: a} sorts perfectly on its own
and becomes mixed the moment the string keys join it, so pnk sync still crashed. Checking the
parts is not checking the whole, and the docstring asserting otherwise is what made it look done.
Two tests could not observe what they claimed, for the same reason. A plain read-write of an
unchanged document short-circuits before the merge runs, so a wanted that deduplicates survived
test_two_identical_link_entries_both_survive and a set-based collapse was invisible. Any test of
reconciliation has to change something first, or it is testing the short-circuit.
A warning is not an error, and a library that downgrades one is changing behaviour. A reused
anchor name raised ComposerError — a YAMLError — before the swap, and after it the document
loads, every alias resolves to the last anchor of that name, and the only signal is a
ReusedAnchorWarning on stderr. Three consequences, none visible in a passing suite: the value
silently changes; read()'s except YAMLError never sees it, because a Warning is not one; and
under this project's filterwarnings = ["error"] it escapes as a bare warning traceback rather than
a named error. Promoting it at the load makes the outcome independent of whatever warning filters
the calling program happens to have set — which is the right place for a property of the file
format to live.
An exclusion list is a set of claims, and claims rot. Every bound on the byte-identity
invariant — indentation, !! tags, anchors, CRLF, BOM, document markers — was prose in a table
until it was pinned by a test. Writing those tests measured two behaviours that were not on the
list at all: a plain (non-recursive) anchor on an empty value is destroyed, where the list
named only the self-referential case; and a file with no trailing newline gains one. Both are
byte changes to a file nobody edited, which is exactly what the invariant claims does not happen.
A bound stated only in prose cannot notice the library moving under it, and cannot be wrong out loud.
It also falsified a changelog line I had written: "!!int, !!float, !!bool, !!seq and
!!map keep working — verified". Verified of loading; the tag itself is dropped on write, so
!!int 3 comes back as 3. True of the value, false of the invariant, and the word "verified" is
what made it read as covering both.
A gate that has never been shown to fail is a claim too. The AST scan now proves it sees all
four shapes of a planted import — including the function-scoped one an import walk cannot reach —
and does not fire on any of the four legal ruamel forms, every one of which contains "yaml". The
stub signature test proves it can fail: writing transform into the expected set failed it,
because transform belongs to dump rather than __init__.
"PyYAML left the runtime" is true of what Pinakes declares and false of what a user's machine
has. Measured on a built wheel: bare, yaml is absent; pinakes[light] has it, transitively from
huggingface_hub. starlette and uvicorn list it too, but only under an extra they do not pull.
So the CI assertion is correctly scoped to the bare wheel — Pinakes never asks for PyYAML — and the
consequence is the part worth remembering: import yaml will succeed in a real install, so a
stray import in src/ would quietly work instead of failing loudly. That is what makes the AST scan
load-bearing rather than a second belt.
The worst defect in this increment was a rule the plan itself wrote. "One instance, reused
rather than reconstructed per call", justified by 282 µs against 399 µs. ruamel keeps the %YAML
directive from the last load() on the instance and applies it to every later load and dump:
read a sidecar carrying %YAML 1.1, then write an unrelated one that never did, and it comes back
with the directive injected and country: NO rewritten to false. The exact corruption this
increment exists to remove, reintroduced across documents, in exchange for 117 microseconds — and
freshly minted sidecars are contaminated the same way. Nothing softer fixes it: resetting version
after the load still emits the directive, pinning it up front is overwritten by the next load. A
performance justification measured in microseconds should be read as an argument that the
optimisation does not matter.
A gate that never reads the artifact it guards is checking a copy. The stub-signature test
listed the symbols in a hand-written Python dict, checked them with hasattr, and compared against
hardcoded signature supersets — so a stub declaring a parameter ruamel does not have was green under
pytest and pyright, which is the single failure decision 20 exists to catch. It parses the .pyi
files with ast now.
A fixture can be right for the wrong reason and hide the defect it was written for. The
two-links-sharing-a-to test edited the first entry, and entries are walked in descending index
order — so the single-pass form it was meant to catch happened to produce the correct answer.
Editing the second entry is the discriminating case: its fallback claims the link the first was
owed exactly, and both relations end up swapped under the wrong comments. Two of this increment's
tests have now needed the specific case rather than a representative one.
main was red for four merges, and local check.sh could not have known (20260801 06:05)¶
Four tests written across L6 and L7 passed on macOS and failed on CI, so 2314dea (L6) and
ed01b00 merged onto a red main and stayed there until L8's verification step 1 looked.
Two causes, one shape: a test that cannot build its precondition does not skip — it asserts the wrong thing.
chmod(0o000)is not a portable way to deny a read. Three fixtures built an "unreadable directory" that CI read anyway:pnk linkreportedno pinakes.toml therewhere the test demandedPermission denied, and'docs/locked/x.md' is not a document in this KBwhere it demandedcannot be read.
The first fix was wrong, and its wrongness is the lesson. It probed whether permissions are
enforced and skipped when they are not — reasoning that CI runs as root. CI is not root: the
probe reported permissions enforced, did not skip, and the run failed identically. Whatever that
runner does with a mode-000 directory, is_file() neither succeeded nor raised EACCES.
Skipping was the wrong shape regardless. It disables the guard exactly where it broke — the
environment the test could not model is the one that most needed testing. The refusal is now
injected: Path.is_file raises PermissionError, which is precisely what the guard exists to
catch, on every platform and with no filesystem semantics in the way. A test for "an OSError
becomes a PinakesError" should raise an OSError, not arrange for the operating system to.
- Two more of the same shape, found only by pushing the fix and watching CI again. A
300-character filename asserted to produce ENAMETOOLONG — the length at which a filesystem
says that is a property of the filesystem, and on CI the name was simply not a document. And an
embedded NUL in an include pattern asserted to raise from resolve() — on CI it raised
nothing, so the test asserted a problem that never occurred. Both now raise the error directly.
Every one of these tests asked the operating system to produce an error, and then asserted on
the answer. That is a test of the platform, not of the guard. The guard's contract is "an
OSError/ValueError from this call becomes a PinakesError" — so the test should raise one.
pathlib's wording is not a contract. A test assertedUnacceptable pattern: PosixPath('.'), which CPython renders asUnacceptable pattern: ''on other versions. The increment's promise is that the pattern the author wrote is named and the otherincludeentries survive; that is what it asserts now. Third instance in two increments of asserting a phrase where the property was meant.
The process failure is the larger one. ./check.sh was green before each merge, and green on
one developer machine is not green on the three-leg CI matrix — different OS, different Python
patch, different privileges. The project rule already says to check whether the latest run on the
default branch actually succeeded; it was not checked after either merge, and the second merge
landed on top of the first failure without noticing it.
gh run list --branch main belongs in the merge sequence, after the push, not in the next
increment's verification step. A red default branch blocks the release either way — finding it two
merges later only makes the bisect longer.
G1 — Is the eval reproducible? (20260801 00:52)¶
Decision 15 said measure before fixing, and the measurement paid for itself twice: once by finding a real defect, and once by contradicting the fix that was nearly written for it.
HIGH — the eval was reproducible by luck, and the luck was invisible. Running the golden set,
editing a document, re-syncing, rebuilding and comparing per-question outcomes: the real [light]
models agreed everywhere. A low-dimensional fake disagreed on one question in 41 between an
incremental sync and a --rebuild. Both facts are the same fact — 384-dimensional cosines almost
never tie exactly, and every tiebreak underneath resolved to chunks.id, the rowid, which
store.py's own schema comment says has no identity across rebuilds. A property that holds because
the corpus never exercises it is not a property, and G5's sign test was about to be built on it.
The fix is total ordering on (documents.path, chunks.ordinal) at three sites plus a stable
argsort; no measured number moved, which is the right outcome for a change that only breaks
ties, and is why this increment rewrites no baseline and needs no amendment to L8 step 5.
HIGH — the fixture was the algorithm, in the one place that judges the algorithm. The first
version of the tests used eight dimensions, reasoning "fewer dimensions, more ties". Swept against
the genuine pre-fix code, eight and sixteen dimensions both reported zero differences across all
four perturbations: collapse the space far enough and every candidate ties, so the ordering
underneath stops reaching the top-k at all. The relationship is not monotonic — 32 caught two
perturbations, 64 caught one, 128 caught two. Had the sweep not been run, G1 would have shipped a
green gate, a green test suite and a live defect, all three agreeing. The sweep is recorded in the
gate's own DIM docstring rather than the conclusion alone, because the next person's intuition
will be the same as this one's.
HIGH — the mutation harness deleted the thing it was measuring. The first mutation run restored
each mutation with git checkout -- <file>. The fix was uncommitted, so the first restore reverted
it; every later mutation then failed to apply to code that no longer had the target, and the suite
ran green against original code four times while reporting "0 failures" as though the mutations
had been survived. It read exactly like a well-tested change. Two rules fall out. Restore from a
copy of the mutated-from state, never from HEAD — git checkout restores to the last commit,
which is a different thing from "undo my mutation" whenever the work is uncommitted. And a mutation
harness must assert that its mutation applied: the rewritten one fails loudly if the target string
is not found exactly once, which is what turned three silent no-ops into an error.
MEDIUM — a stable sort needed its own test, and the obvious one was vacuous. kind="stable"
changes nothing a repeated run can observe: on a fixed input array NumPy's introsort is
deterministic. It changes what happens when the array grows, because partitioning depends on the
whole array — measured at 500 of 500 random tie-heavy arrays reordering their original entries. The
first test written for it used four tied chunks and passed under the mutation, because NumPy uses an
insertion sort below roughly sixteen elements and insertion sort is stable whatever kind says. A
fixture can be too small to contain the behaviour it is named after.
What the increment ended up asserting, and at which level. Three end-to-end tests state the property G5 needs, over the committed corpus and questions; four site tests each drive one ordering decision directly and are the mutation targets — one mutation, one failing test, verified for all four. The two levels are not redundant: the end-to-end tests can only observe ties the corpus happens to contain, which is precisely how the defect survived three releases.
The adversarial pass over G1's own diff (20260801 01:25)¶
Six findings, four of them real defects in work that was already green.
HIGH — a gate advertised a field it had retired. _plant rewrote the reranker's model name
and left [retrieval.confidence] fitted_for naming the real one, so _confidence short-circuited
and all 41 questions scored unknown. Both the gate's docstring and the tests claimed to compare
the confidence label. Naming the reranker was not enough either: the committed thresholds were
fitted on a real cross-encoder's logits and sit below every score the fake can emit, so the label
became a constant high — still unable to move. Thresholds inside the fake's range give
35 medium / 5 high / 1 low, and the field is finally live. The class of defect matters more than
the instance: a fixture that rewires half of a calibrated pair silently disables the thing it was
calibrated for, and nothing fails.
HIGH — the plan still asserted what the measurement disproved. Decision 15 says a final tiebreak
would be "a provable no-op" because cross-document ties are totalised by documents.path and
rowid order is ordinal order. Both premises are true about writes and irrelevant to the
output: documents.path cannot separate two chunks of the same document, and an incremental
sync by definition does not rewrite the files it did not touch, so rowid order stops matching corpus
order at the first re-chunked file. The plan is an executor doc; leaving that cell intact would have
licensed a G2–G5 executor to skip a tiebreak for a reason this increment measured to be false.
MEDIUM — half the gate's sweep has never observed anything. Of its four perturbations, added
and removed report zero differences against the genuine pre-fix code at every width swept
(8/16/32/64/128), while edited and renamed bite. --inject-difference cannot reveal this: it
corrupts all four alike. The gate now states it. A gate's own justification is a claim like any
other — this one said "it sweeps four ways where the tests exercise one", and two of the four were
along for the ride.
MEDIUM — the contract's file table was checked against the wrong question. It compared the two
tracks' owned files and never asked what a new gate touches. Every gate edits check.sh,
ci.yml and tests/test_check_script.py, which both tracks append to at the end of the same
regions; and G1 necessarily edits search.py and store.py, which the table lists under neither
track, because reproducibility is a property of core retrieval. Widened, with the reason.
LOW, and recorded rather than fixed — making the BM25 cut total costs a join: +11.5 ms on a
50k-chunk corpus where every chunk matches every term. That is the worst case a planner can be
given, the correctness is not optional, and the number now sits in docs/STATUS.md so a later
change can argue with it.
What the pass confirmed, having tried to break it: bm25() still resolves with the alias
present and returns byte-identical rows; the join multiplies nothing (both sides unique); the
load_vectors reordering costs nothing measurable; graph/provider.py, the other caller, reduces
to a per-document max and is order-independent; the four site tests each fail against pre-fix code;
and the artifact paths, cache keys and macOS wheels in the cross-machine job all resolve.
L6 — pnk link (20260801 01:41)¶
Every review commit on this increment found defects in the one before it, and most of them found
the previous commit's own fix or claim rather than something it had missed — 3ce150e (review 1's
containment fix had traded one defect for two), 986faf3 (review 2's fix was right; its stated
justification was not), 7b3f0a3 (the escaping-error class sat one line above the try added for it),
9c8f667 (the totality fix re-anchored the walk on the working directory), cdee8d8 (the test for
an untested branch entered it, but its assertion held either way), dbebd8b (a severity asserted,
not measured), and the last three, which took four goes at one containment rule.
No total is given, deliberately. Three drafts stated one and all three were wrong, because it
changes depending on whether 8b and 9b count as rounds of their own — and the last wrong figure
was introduced by the commit correcting the one before it. git log main..HEAD is the answer, and
it cannot go stale. What follows is the state after all of them, not a log:
the rule is to rewrite to the current state rather than layer corrections, and earlier drafts of
this fragment broke it four times — describing a concurrency
scenario a later round had disproved, calling every self-link a typo after the fix for the other
case existed, counting the rounds that had happened when it was written rather than the ones that
had, and asserting a safety property ("Path.resolve() is safe at both call sites") that was
wrong twice over: strict=False suppresses OSError, not the ValueError an embedded NUL raises,
and there are six Path.resolve() sites across the two modules rather than two.
One defect class, six instances, and why fixing it at the call site produced them¶
HIGH. cli.main catches PinakesError. Anything else is a traceback on a user's terminal — or
on an unattended post-commit hook. Six calls in this increment's blast radius raised something
else:
Path("~nosuchuser/x.md").expanduser()raisesRuntimeError, on<source>inlink.py. It bought nothing either: a~that does expand lands in$HOMEand is refused by the containment check on the next line. Copying a call across a boundary copies its justification too, andlinkscan's need for it (a[[links.kb]] pathmay be~/kbs/partner) did not survive the trip.Path.is_file()ignoresENOENT,ENOTDIR,EBADFandELOOPand raises everything else — so an unreadable parent directory (EACCES) and an over-long name (ENAMETOOLONG) on the same source path.- The
is_file()/is_dir()pair one branch over, in_via_alias: a partner KB directory this user cannot read raisedPermissionError. resolve_path, on the line immediately above thetryjust added for (3).- The same three, in the module
link.pycalls into.linkscan.scan_one's docstring promises "Never raises: every failure comes back inissues", and all of them sat in the three lines that ran before any handling did — sopnk syncon a hook became a traceback. There since L2. resolve_pathagain, bare inscan()'s freshness branch — which plainpnk synctakes, so a partner path that stopped resolving crashed everygit commitinside the TTL. The branch had no test at all.
Fixes 1–5 each wrapped the instance in front of them and stopped. What closed the class was moving
the guarantee into resolve_path itself — a guarantee three call sites each have to remember is a
function with the wrong contract — which then removed the wrappers fixes 4 and 5 had added.
The first version of that fix introduced a worse defect than the one it closed, and this is the
part worth keeping. resolve_path was made total: on text no filesystem call accepts it returned
Path(raw), the declared text, so an error could still name what the author wrote. That value is
relative, and five consumers use it as a filesystem base — (path / MANIFEST_NAME).is_file(),
why_not_a_kb, partner_sources, sidecars_under, _doc_id_of. So the walk silently re-anchored
on the process's working directory: the precise thing resolve_path's own first paragraph says
it exists to prevent, reintroduced four paragraphs below by the round that wrote it.
With a directory of that literal name in the CWD holding a readable pinakes.toml, pnk sync
walked the decoy, found nothing, stamped the scan complete — and replace_reverse_links deleted
every inbound row the real partner had, with report.ok true and no issue raised. That is the real
consequence, and it is silent data loss.
Round 8 also claimed pnk link would write the decoy's ULID into the real sidecar, permanently.
It would not, and round 9 reproduced the refusal. _document_in compares an absolute
joined.parent.resolve() against the relative root, which can never be is_relative_to, so it
fires before any sidecar is read — 'docs/one.md' is outside `partner`, which tells the user the
path they typed correctly is wrong and names neither the KB path nor the expansion failure. A
message defect, not corruption.
Three things kept that overstatement alive for a round. The true account was already in the tree —
link.py's own comment describes the misleading refusal — so the increment carried both versions
at once. The regression test's docstring asserted the severe reading, and under the round-7
mutation it failed on its first assertion, so the two that encoded the severe claim were never
reached: a test that fails proves the mutation is caught, never that it is caught for the stated
reason. And the claim was written from the mechanism (a relative base re-anchors the walk →
therefore the walk completes) rather than from running it. That is the same "prose written from the
design" failure as the two documentation defects below, in a commit message and a retrospective —
the two places where being wrong is hardest to notice later, because nothing executes them.
The answer is None, not a fallback value: text that names no path yields no path, and pyright
makes every caller say what it does instead — a type-checked obligation rather than a remembered
one, which is the same lesson one level up. The declared text is still what the message names; it
was always available as linked.path, which every caller already held. A total function is not
automatically a safe one — totality only moves the failure from a raise to a return value, and a
return value that is the wrong kind of thing is harder to notice than an exception.
Four tests fail against the round-7 shape, verified by mutation — including the two written for it, though one of those for a different reason than its docstring gave (above).
A defect class is not closed until it has been searched for, and the search is mechanical: list every call in the module that touches the filesystem and ask of each which errno it swallows.
Path.resolve() belongs on that list and was wrongly excused twice. strict=False suppresses
OSError; it does not suppress the ValueError raised for an embedded NUL, which tomllib
accepts in a manifest and pathlib will not open. Enumerated rather than excused, there are six
sites: _document_in (link.py:298) resolves a path built from user text and is now guarded and
tested; resolve_path (linkscan.py:178) is the fix above; and sidecars_under has four —
anchor, the roots entry, the pattern probe and the per-candidate check — all inside the
caller's except (OSError, ValueError, NotImplementedError, PinakesError).
The enumeration is the point: "safe at both call sites" named neither the number nor the reason,
so it could not be checked without redoing the work — whereas a count with line numbers is wrong
the moment it drifts, and says so. It has drifted twice already: round 8 corrected an earlier
version that called two of the sidecars_under sites partner-controlled when one is not, round
10's own fix added the fifth site, making "four" stale in the same commit that relied on it; and
round 13's added the sixth the same way.
The containment check took three spellings, and the first two were each wrong in one direction¶
HIGH. _document_in decides whether a path names a document in this KB.
joined.resolve()— the original — follows the final symlink before checking, so a symlinked document was refused as "outside this KB", with a remedy repeating the path the user had typed correctly.pnk syncindexes such a file,pnk doctorcalls its sidecar readable andpnk linkstraverses it; onlypnk linksaid it was not there, and nothing could link it in either direction.os.path.normpath— round 1's fix — follows nothing, so a symlinked directory underdocs/passed containment: the write went out of the KB through it, and in the other direction minted a permanentpnk://to a ULID this KB will never index, becausePath.globdoes not recurse a symlinked directory. It simultaneously refused a legitimate absolute path whose ancestor is a symlink — the ordinary shape on macOS (/tmp→/private/tmp) and behind any symlinked checkout — becausemanifest.loadresolves the root, so a verbatim comparison could never match it.joined.parent.resolve() / joined.nameis right in both directions. The directory chain is followed, so an escape through it is caught and a symlinked ancestor lands inside; the final component is left alone, so the document's own symlink is irrelevant — which is correct, becausePath.globdoes yield a symlinked file.
normpath must also not run first: it collapses docs/link-to-elsewhere/../x.md to docs/x.md
textually, turning an escaping path into one that looks contained. resolve() on the parent
collapses .. after following the links it sits behind.
The docstring was wrong for longer than the code. Two drafts justified the check with "what
decides membership is the path under [sources]" — a rule the check has never implemented, since it
compares against the KB root. Round 2 quoted that sentence as the lesson and left it in place;
round 3 found it still there. The residual it was papering over is now stated instead: a document
inside the root but outside [sources] can be linked, and the link will not resolve until that
document is ingested. Answering the [sources] question properly means re-implementing
walk_sources including its globs, and refusing a "link it now, ingest it next" order of work that
costs nothing.
Two documentation claims the code contradicted, in prose written from the design¶
HIGH. The new pnk link section told the reader that a pnk:// URI pointing at a KB not on
this machine is fine because "pnk doctor reports a dangling target; pnk links lists it under
unresolved". Neither happens. doctor.py filters its dangling list to this KB — the cross-KB
check is L7, the next increment — and provider.py's unresolved carries a docstring
explicitly refusing to widen: "a cross-KB target cannot be checked from here without the other KB,
and reporting one as unresolved on that basis would be asserting something this index has no
standing to know." A reassurance was invented for the one case the section was telling the reader
not to worry about, and half of it described something the design had already declined to build.
The replacement prose then made the same mistake twice more, which is the finding worth keeping.
Round 1's fix illustrated the missing lock with a post-commit hook firing a paid extraction — a
scenario hooks.py structurally prevents. Round 2's fix replaced that with "the one sync that
rewrites an existing sidecar is a paid extraction", which sync.py's --force-plus-free---extract
override falsifies, and which this increment's own edits to DESIGN, MANIFEST and CLAUDE.md all name
the carve-out for. A correction is a diff and earns the same verification as the line it
replaces; three rounds of unverified prose about the same paragraph is what happens otherwise.
Fixtures that were representative rather than discriminating¶
MEDIUM, four times. A test can be green because the code is right or because the input never reaches it, and the two look identical from the outside.
test_no_line_outside_the_links_block_changes_when_a_link_is_addedused a sidecar with a populatedtags:list, whichwrite()short-circuits as unchanged and therefore never touches. It could not have failed. Meanwhiletags:andprovenance:written with nothing under them were being rewritten totags: []andprovenance: {}on everypnk link— two lines changed outside the block, in the increment whose test says none are, against a promise stated as byte-identity. Reachable before L6 only from a paid PDF extraction, which is why L5b's sweep missed it;pnk linkreaches it on a first link, the common case. The siblingtest_a_known_key_with_a_null_value_does_not_crash_the_writerparametrises exactly these three keys and asserts only"id:" in text: it pins the absence of a crash and nothing about the value.- The embedded-NUL test put its NUL in the filename —
docs/a\x00b.md— where only the parent is resolved, so it never reached the guard it was written for and passed against the ordinary "not a document" refusal. Moved into a directory component. Caught by mutation, not by review. assert "outside" in messageagainst a fixture namedoutside.md, andassert "partner" in messageagainst atmp_pathending in/partner. Both were satisfied by the interpolated path, so the reason could have vanished from the wording with the test still green — proven by rewording the error and watching all 29 pass. Fixtures renamed, phrases asserted.- A fixture stops reaching its guard when a later fix gets there first, and nothing says so. The ordering test for the containment check was retargeted twice — once when the static refusal was added, once when that learned to resolve the prefix — because each fix caught its input earlier, leaving the test green and its guard unexercised. Both times the mutation found it and the reading did not. Re-run the whole mutation battery after every fix, not only a mutant for the fix itself: a fix can silently disarm a test written for something else.
- The test written for the freshness branch — the branch a finding had just called untested —
asserted only
report.ok, which holds whether that branch runs or not. Proven by forcingis_staleto returnTrue: the branch never ran and the test still passed. A skipped-fresh row carries no issue, solink_scanis the assertion that discriminates. Found by a reviewer, not by the round that wrote it, in the commit whose message called its other two fixes mutation-verified — "mutation-verified" is a per-assertion claim, not a per-commit one.
A docstring claiming a safety property its function cannot have¶
MEDIUM. _doc_id_of's owner argument was documented as preventing the pnk://self/…
retargeting defect. It cannot: only .id is returned, so owner never reaches an observable —
measured both ways, the mutation is caught by no test and the output against a partner sidecar
carrying the exact retargeting shape is byte-identical. The protection is real but lives in
linkscan.scan_one, which keeps the links it reads. A plausible rationale attached to the correct
line is harder to catch than a wrong line, because reviewing it means re-deriving the claim rather
than reading the code.
Mutation testing: a killed run poisons everything after it¶
HIGH, methodological. The first mutation run blew a two-minute timeout and was killed
mid-mutation, so its finally never restored the source. The next run's pattern then failed to match
the already-mutated file, reported "pattern not found", skipped — and that guard stayed disabled for
all ten mutants that followed. The signature is unmistakable once known: one unrelated test failing
on every mutant, including mutations that cannot reach it.
Two things made it recoverable: the disabled guard had its own test, so the failure was loud, and
./check.sh had been green minutes earlier, which dated the contamination. The fix is a baseline
snapshot taken before the first mutation and asserted after every restore — not git diff --quiet,
useless in the increment's own worktree where the source is legitimately dirty. Scope the run to the
modules under test, too: the full-suite run is what blew the timeout that caused this.
Every fix was mutation-tested against the test written for it, and three escaped, each in a
different way that "green" could not distinguish. The NUL guard had a test whose input never
reached the line. The containment-ordering test stopped reaching its branch twice, when a later fix
caught its fixture earlier. And review 14's next() guard had no test at all: the "" and "."
written for it raise at the glob() call, not at the step, so the guard one line down was never
executed — which review 14's own commit message called "eleven mutants, each killed by the right
test", and this paragraph called "all but one".
The method is now: mutate every behaviour in the function, not the ones the diff touched. That
standard was stated before it was met. The sweep that first claimed it covered three behaviours;
an independent 47-mutant pass over the whole of sidecars_under and scan_one killed 33 and left
14 alive — 2 provably equivalent, 12 unpinned. Every one of the 12 was checked and the code is
right in each, so they are coverage rather than defects, and they are listed rather than closed:
the two halves of the exclude disjunction (a deliberate mirror of sync._excluded), the
continue that bounds a pre-walk escape, .resolve() on anchor and on base, the is_file()
and sidecar-suffix skips, the two sorted() calls, partner_sources raising, and
LinkTargetMissingError's count.
Naming them is the point. A number for the battery is unverifiable afterwards — the runs leave no artefact — but which behaviours are unpinned is checkable by anyone who repeats the sweep, and that is what a later reader needs.
One mutant is genuinely equivalent: substituting the locally declared [[links.kb]] id for the
partner's own when writing an alias target changes nothing, because the refusal above has already
established the two are equal. Saying so is part of the result — the rule is enforced by that
refusal, which is caught, and the docstring records it so nobody simplifies the variable away on
the grounds that they are the same.
Green expires at the next keystroke¶
HIGH. ./check.sh ran green, then a docstring was reworded to 101 characters, then the increment
was committed. Under set -e a failing ruff check means the eleven gates after it never ran either:
the increment's own verification stopped at gate two, unnoticed, because the earlier green run was
still in mind. The rule already says green-before-review; what this adds is that the run has to be the
last thing before the commit, including after an edit to a comment.
A containment rule argued in prose and implemented for half its inputs¶
HIGH. sidecars_under reads a partner's [sources], and its docstring says why that input is
untrusted: "without the same check here, a partner manifest could point the walk at any directory
on this machine, and roots = ["/"] would be an unbounded walk on a post-commit hook." The check
existed for roots. include is exactly as partner-controlled and had none, so
include = ["../../outside/*.md"] walked out of the partner KB and this one recorded inbound links
from files the partner does not own — complete true, so sync persisted them.
The line that looks like the guard is not one. candidate.relative_to(root) ran on every match,
and relative_to is purely lexical: docs/../../outside/planted.md is relative to the root as a
string, returning docs/../../outside/planted.md rather than raising. A .. is only collapsed by
resolving, which is what the roots branch does one block above and this one did not. Two spellings
of the same rule, ten lines apart, one of them not implementing it.
The fix then took four goes, and every wrong one came from spelling the rule differently from
the place that already had it right. link._document_in resolves the parent and leaves the
final component: the directory chain is followed, so .. collapses and an escape through a
symlinked ancestor is caught, while the document's own symlink is irrelevant. That is the rule.
Each attempt reinvented it:
- Per candidate, after globbing. Correct about what to refuse, but it refuses the results:
globhas already enumerated and stat'd the whole tree by the time the first match is inspected, soinclude = ["../../../../**/*.md"]still walked the machine on everypost-commit. And an escape setscompletefalse, so nolast_scanis written and the TTL cannot suppress the retry either — unbounded, forever. - Refuse any pattern containing
.., before globbing. Bounded, and wrong in the other direction:../notes/*.mdstays inside the KB and the partner's ownwalk_sourcesingests it. This KB called a legitimate manifest an escape and then never refreshed that partner again. Refusing a partner's valid configuration is the same defect as accepting an invalid one. - Resolve the prefix before the first glob component. Defeated by a pattern that starts with
one:
*/../../../outside/**/*.mdhas an empty prefix, so the check passed unconditionally and the..ran insideglob— attempt 1's defect, reachable again. It also refused a fixed pattern naming a symlinked document, because with no glob component the "prefix" is the whole path and resolving it whole follows the final symlink:include = ["alpha.md"]refused whileinclude = ["*.md"], reaching the same file, was accepted. - Join the whole pattern and apply
_document_in's spelling to it. A glob component is just a name that does not exist, whichresolve()collapses lexically, so oneresolve()answers it with no enumeration. Ten patterns measured — a..staying inside, a directory genuinely nameda..b, a literal bracket, two escapes, one behind a leading glob, a symlinked directory under a glob, a symlinked document by both spellings, and an absolute — all correct, escapes refused in 0.12ms without touching a 3000-file tree. None of the ten contained**followed by... - Drop
**from the probe.**matches zero or more components whilePath.partscounts it as one, so keeping it let a following..cancel it and the probe landed one level below where the walk goes.**/../../**/*.mdprobed inside the KB and walked the directory containing it, recursively — linear in the outside tree, and silent, because an escape is only noticed once a candidate is yielded and that pattern matched none. Dropping it is exact rather than merely conservative: each component**expands to is one a following..then pops, so the zero-expansion is the highest the walk can reach.
Attempt 4's measurement was real and its ten patterns were all correct. It was the sampling that was wrong — ten hand-chosen inputs, none of which combined the two tokens whose interaction is the whole difficulty. A table of cases proves the cases in it, and reads like proof of the rule.
An absolute pattern is refused separately, because glob cannot walk one wherever it points —
including at this KB's own docs/. It had been folded into the escape message, which was simply
false for that case.
The lesson is not about paths. Three of the four attempts were written by reasoning about the problem afresh instead of copying the spelling from the function twenty lines away that had solved it. A rule implemented twice is a rule with two behaviours; the fix was to make the third implementation textually identical to the first two and say so in all three.
Each attempt was found by mutating its predecessor, and three of them disarmed an existing
test: a fix that catches its input earlier leaves the older test green with its guard
unexercised. By the last round two guards written in earlier increments had gone dead this way —
L2's roots containment check, whose test was satisfied by the substring "outside the KB" that the
new per-candidate check also emits, and scan_one's own except around the walk, whose only
input (include = ["/etc/**/*.md"]) the absolute branch now answers first. Both were found by
mutating behaviours this increment never touched.
So the rule is stronger than "re-run the battery after every fix": the battery is over the whole
function, not the diff — and a promise worth a guard is pinned directly (here, by making the walk
raise) rather than through an input that some later fix can intercept. Running it that way in the
next round found three more unpinned behaviours: the sidecar existence check, which predates L6
(7570a69), and the */** boundary and the next() guard, both written two rounds earlier by
this increment (425d106). The sharper reading is the second one — the code least likely to be
pinned is not the oldest, it is what a recent fix added while attention was on the defect it
closed.
The fix for "one bad entry is not the end of the partner" was itself incomplete, one line either
side of where it landed. probe.parent.resolve() sat above it unguarded, so an embedded NUL in
any but a pattern's final component still raised out of the function; and Path.match("") sat
below it, so an empty exclude entry did the same — a case the new guard's own comment cited by
name as its reason for being scoped tightly, and then did not handle. Both produced the outcome the
commit had just declared impossible. A guard placed by reasoning about one call is a guard for
one call; the same mechanical sweep that closes an escaping-error class — list every call that
can raise, ask what each does with bad input — is what this needed, and it is the third time in
this increment that lesson has been relearned rather than applied.
One more from the same sweep: the containment predicate was fooled by a trailing .., because
Path("/kb/..").is_relative_to("/kb") is lexically true. The final component is left unresolved
so a symlinked document stays readable, and .. is never a document.
sync.walk_sources has the identical shape for the local manifest and is not fixed here: it
is the user's own configuration rather than a partner's, and changing the engine's document walk is
not this increment's to do. Reported instead, and now scoped as its own increment and PATCH release
in plans/20260731_2128-source-walk-containment.md — which measured a third defect this pass had not: an
absolute local include is a raw NotImplementedError out of cli.main, the same escaping-
error class L6 spent four passes closing on the partner side.
Smaller things¶
pnk link A Awrote a self-loop, which says nothing and would return the document as its own neighbour. Refused now — and worded for both ways of arriving, because only the ULID is known here: the target really is this document, or it is a different file carrying the same id, which is a KB fault in its own right. "would link to itself" told someone who had named two different documents that one of them was itself, and pointed at neither the duplicate norpnk doctor, which finds it.os.replaceonto a symlinked sidecar destroyed the link and left a regular file, with the real file elsewhere still holding the old text.create()guards this explicitly;write()did not, andpnk linkis the first command a person points at a file of their own choosing. It now writes through the link.- The error fallback named the local KB root while the comment beside it claimed it named the declared path — reporting an unrelated readable directory for a failure that had nothing to do with it. Neither of the two tests written for that fix caught it: both asserted only the message prefix.
why_not_a_kbreproduced, one level down, the defect its own docstring exists to record. The three-way split was added because anis_dir()split called an existing regular file "no such directory" — "the one answer a person would check and find false". But the caller's probe isis_file(), so apinakes.tomlthat exists and is a directory, or a symlink to nothing, fell through to "no pinakes.toml there" with the file plainly visible inls. Found in review 9 by reading the docstring's justification against the code beneath it, which is a cheap check worth running on any function whose comment argues for its shape.pnk linktakes no lock, so a concurrent write to the same sidecar can lose one side's change. Rename-atomicity prevents a torn file, not a lost update, and DESIGN §2.2 now says which.- STATUS's surface you can use today table had no
pnk linksrow at all, an hour and a quarter after it shipped in 0.5.0 (20260731 11:27; the row landed inb96d247, 12:44) — found while writing the increment by reading the neighbourhood rather than the diff.
L7 — pnk doctor's link checks (20260801 05:40)¶
The check read a partner's index, and DESIGN §6.2 forbids exactly that¶
HIGH. The cross-KB check opened <partner>/.pinakes/index.db read-only to ask whether the
target document exists. §6.2 rules that out in the sentence that defines reverse links: they come
from the other KB's committed sidecars, "not its index, which is gitignored and simply absent
in a fresh clone, and which could not be read without holding a second KB's lock" — repeated
verbatim in linkscan's module docstring, which is the module the check imports from.
mode=ro is not enough, and this is the part worth keeping. Measured: a read-only connection still
materialises index.db-shm and index.db-wal inside the partner's .pinakes/, and cannot
checkpoint them away on close. A diagnostic command wrote into a KB it was only asked to look at.
Two more consequences fell out of the same choice: a partner cloned but never synced answered
"missing" for every target, and a partner whose .pinakes/ is mode 0500 degraded silently with an
internal StoreError message that misdiagnosed the cause.
The fix is the machinery L2 already had — partner_sources + sidecars_under + read_sidecar —
which is design-conformant, works on a fresh clone with no index at all, and is now tested that way.
The rule was in the imported module's own docstring. Not a subtle design point: a paragraph in the file the new code imports three names from.
The metric's numerator and denominator came from different populations¶
HIGH. Coverage is COUNT(DISTINCT src_doc_id) / active. sync's SoftDelete sets
state = 'deleted' and drops the chunks — it never deletes that document's origin = 'sidecar'
rows. So a deleted document still counted toward the numerator while leaving the denominator,
and the headline number of this increment reported 2 of 1 documents linked (200%).
A ratio built from two queries is two populations until something makes them one. The join is one line; noticing it needed one was the work.
The declared [[links.kb]] id is not evidence of which KB is at that path¶
MEDIUM. The check keyed partner document sets on linked.id — the local declaration.
linkscan.scan_one refuses that substitution with LinkedKbIdMismatchError, and DESIGN §6.2 rule 1
states it as a rule, because trusting the manifest files another KB's links under this alias.
Measured both ways with a manifest declaring X over a partner whose real id is Y: a target that
existed in Y was reported unresolved, and one that did not was silently resolved.
Two directions need two tests, and only one of them is obvious. Filtering on the declared id also skips a partner whose real id is the one wanted — a dangling target that goes unreported rather than misreported — and that mutant survived until a test was written for it specifically.
Four remedies could be blanked with the suite green¶
MEDIUM. The plan required "every new WARN carries a remedy" precisely because the meta-guard
(test_every_problem_carries_a_remedy) runs on a fixture where these checks are OK and carry no
problem. The helper written to stand in for it asserted is not None — which "" satisfies, while
the guard it substitutes for asserts truthiness. Four of five remedies were emptiable.
A stand-in for a guard has to assert what the guard asserts. It now returns the string and each caller asserts a phrase from it.
A test named for a guard, authoring nothing that reaches it¶
MEDIUM. test_an_unreadable_linked_kb_path_is_a_warning_not_a_traceback was written for the
sentence "a diagnostic command reporting a traceback is the one outcome pnk doctor may not
have", and named why_not_a_kb's "third caller needing the same try". It authored no cross-KB
link -- so wanted was empty, _unresolved_cross_kb returned before touching the partner, and the
test pinned the guard in _linked_kbs and neither of the two in the function the review had just
added. Both are load-bearing: a partner directory behind a mode-0000 parent raises PermissionError
out of partner_sources, and a roots entry carrying an escaped NUL -- which tomllib accepts and
Path.resolve does not -- raises ValueError out of sidecars_under.
Third time in two increments that a fixture stopped one step short of its guard, and the shape is always the same: the test sets up the failure but not the demand for it. An unreadable partner is only reached by code that has a reason to read it.
The dangling-link side of the soft-delete interaction had the same gap in miniature -- the fixture
that proves the numerator excludes a deleted document already produced 1 dangling inside this
KB in the detail it held, and asserted nothing about it. The fix was one line in a test that
already existed.
Mutants that were not the logic they claimed¶
Methodological. Four "blank the remedy" mutants replaced "A cross-KB target…" with
"" or "A cross-KB target…", which evaluates to the original string. All four reported SURVIVED,
which read as four coverage gaps and was really one broken harness. Rebuilt to replace the whole
remedies.append(...) call, all four die.
This is the second increment where a mutant that did not reproduce the real prior logic was briefly taken for a result. The check is cheap: a mutant that survives should be run against the case it claims to break before it is believed.
G2 — The headroom measurement, and what it found (20260801 12:14)¶
G2 was built to answer one question: is the graph release's gate reachable at all? It is not, on
this corpus. The precondition needed at least 7 of the ~18 single-KB multi-hop questions to fail
today. One fails. G3 does not start, and the answer arrived before anything bumped
schema_version and forced every KB in existence to rebuild — which is the whole reason the
measurement was sequenced first.
HIGH — the demo corpus has no tags, and one directory. The plan assumed otherwise.
tests/demo-kb is thirty documents in a flat docs/, and not one sidecar carries a tags key.
With mentions cut (decision 6), that leaves exactly one structural edge kind that crosses a
document boundary: co-located, through a single thirty-way directory hub. shared-tag derives
zero edges. sibling, parent/child and in-section are all intra-document and cannot bridge
two evidence documents by construction. So the "derived structure" the graph release exists to
evaluate is, on the committed corpus, one hub — and G5's own text reasons about "the directory
layout and tag vocabulary of tests/demo-kb" as though a vocabulary existed. It does not.
Whatever G3 would derive here, a result carried by it is a claim about one directory.
HIGH — a reachability probe on a thirty-document corpus is close to vacuous, and the reason is
not the probe. candidates_per_source is 30 and the corpus has ~30 chunks, so the vector channel
already returns essentially every document with a positive cosine: the funnel sees the whole
corpus on every query and then cuts to final_k = 5. A failing question is therefore almost never
a recall failure the channel could fix by reaching further — it is a ranking failure. That is why
the probe reports at-seed separately from liftable: two of the three questions the fake backend
called liftable were already among the fused candidates and merely ranked below the cut, having
traversed no edge at all. A ceiling built from those would have read as headroom and been none.
The numbers, real [light] models, tests/demo-kb at 20260801 12:14. 18 multi-hop questions,
1 failing (mh-withdrawn-collection-register), liftable 1 without authored edges and 1 with,
beyond-2-hops 0, membership-only 0. Required: 7 failing and 7 liftable without authored
edges. It fails on the first clause by six.
The questions were frozen before the probe ran, and were not re-authored afterwards. Thirteen new multi-hop chains, authored from pairs of documents that between them answer one question and share no vocabulary on the thing that joins them, with the second hop phrased in the first document's words. Seventeen of eighteen are answered correctly. Re-authoring them until seven fail is fitting the question set to the gate — the circularity decision 14 removed by cutting cross-KB questions, and undetectable once done. The honest reading is that a corpus of thirty short, topically disjoint documents cannot produce a hard multi-hop set: picking 5 of 30 is not a discriminating retrieval task, and the pipeline scores 0.94 on it.
MEDIUM — the fake backend and the real models disagree about the shape of the answer, and only one of them is the measurement. Under the deliberately tie-heavy hashing fake the same set shows 9 failing and 3 liftable without authored edges (6 with) — the exact with/without gap the plan predicted L1's hand-authored links would produce. Under the real models both collapse to 1. A measurement taken on the fake would have reported a different failure of the precondition and invited the wrong remedy.
MEDIUM — _score read Outcome objects, so the artifact could not have been re-scored. Every
metric is a function of five fields per question, but the scorer was written against the in-memory
type. Splitting score_rows(rows) out is what makes the committed artifact checkable offline, and
it is what test_the_committed_41_score_exactly_their_pre_growth_values runs on: no weights, no
network, and the 41 pre-growth questions reproduce their baseline byte-identically — measured
on macOS against a baseline written by CI's ubuntu runner, which is the same cross-machine
agreement G1's new CI job independently confirmed the same morning.
LOW — the first --fake run silently asked for real weights. _fake_kb asserted each manifest
substitution appeared exactly once; provider = "fastembed" appears twice (embedding and rerank),
so the assertion fired and the run aborted — correctly. Loosening it to "replace whatever is there"
would have left the rerank provider real and made an "offline" gate download a model. The expected
occurrence count is asserted per line, not assumed.
Review pass — MEDIUM, the empty-set skip could swallow a typo. load_questions read
raw.get("questions") or [], so a file whose key was misspelled produced an empty list — which the
new skip then reported as "a template deliberately ships none" and exited 0. Under the old
behaviour that file failed. An absent key is now an error and only an explicit questions: []
skips; the two cases are genuinely different and the skip is only safe because it can tell them
apart.
Review pass — MEDIUM, read_outcomes promised more than it delivered. Its docstring said it
"refuses a file whose rows are not rows — never a partial read", and a row missing confidence
raised a bare KeyError from the middle of the loop. Every one of the five fields reaches a metric
in score_rows, so a row missing one cannot be scored; they are now checked by name.
Review pass — LOW, fused_candidates is a stage, not an entry point. It does not run
check_coherence, because search does that before calling it. Anything reaching for the new
public function directly is querying an index that may have been built by a different embedding
model, which returns confident nonsense rather than an error (§4.4). Stated in the docstring rather
than duplicated in the function, since search would then run it twice on every query.
Source-walk containment — one rule, three sites, enforced at one (20260801 13:28)¶
The durable lesson: a containment rule argued in prose beside one of its two inputs is a rule for
one input. manifest._sources states that a source root must stay inside the KB and enforces it
for roots. include sat two lines away, validated nowhere. The same lexical
candidate.relative_to(root) non-guard then appeared at three sites — linkscan.sidecars_under
(fixed in L6 review 10), sync.walk_sources, and the sidecar sweep beside it — and the one whose
docstring carried the argument was the one that did not implement it.
All three defects were re-measured on 0.7.0 before anything was changed, against a plan that had
measured them on 0.5.0 at 900aae7. All three still reproduced, unchanged:
| Before | After | |
|---|---|---|
include = ["../../outside/*.md"] |
2 indexed, a sidecar minted outside the KB, document keyed docs/../../outside/secret.md |
ManifestError at load, naming the pattern and the root |
include = ["/abs/path/*.md"] |
bare NotImplementedError traceback out of cli.main |
ManifestError: "is an absolute path", with its own remedy |
docs/escape -> /outside, include = ["*/*.md"] |
1 indexed, a sidecar minted outside the KB |
0 indexed, the pattern reported, nothing written outside |
HIGH — a fourth defect, found by a test that was meant to pin correct behaviour. Layer 1
deliberately accepts a .. pattern that lands inside the KB (include = ["../notes/*.md"] from
docs/), because what matters is where a path lands rather than whether .. occurs in it. The test
asserting that then failed on the document's key: relative_to is lexical, so it returned
docs/../notes/n.md. Measured with roots = ["docs/", "notes/"] and
include = ["../notes/*.md", "*.md"], one file on disk produced one indexed document and two
failures — "appeared after the walk had already read this directory" — because the sidecar found
under one key was invisible under the other, and the unmatched sweep reported an indexed document as
unmatched. Nothing in the plan predicted this; it exists only because the legal .. case had never
been exercised.
The fix is lexical collapse (posixpath.normpath), not resolve(). Resolving would follow a
symlinked directory and re-key every document under it — docs/alias/x.md becoming
docs/real/x.md — which on an existing KB is a path change against a permanent identity. Lexical
collapse touches only paths containing .., and every one of those is already broken today.
Containment does not rely on it: the per-candidate check resolves.
The predicate was copied from linkscan.sidecars_under, not re-derived, and that was the whole
point. Reviews 11, 12, 13 and 14 each found a different defect in a different spelling of this one
rule: refusing any .. (rejects a valid manifest); resolving only the prefix before the first glob
component (defeated by a leading *); resolving the whole path (refuses a symlinked document
while accepting the same file via *.md); and keeping ** in the probe (it matches zero
components while Path.parts counts it as one, so a following .. cancels it). Re-deriving would
have cost that sequence again for nothing.
MEDIUM — the static layer is the bound, and the dynamic layer is the guard; neither covers the
other. Checking candidates after globbing refuses the results while still paying for the
enumeration, which is what the roots rule exists to prevent —
test_an_escaping_pattern_is_refused_without_enumerating_the_tree counts entries pulled from the
generator, not resolve() calls, because the cost being avoided is the walk itself. And a symlinked
directory has no .. and no absolute path, so it is invisible to any load-time check. The
per-candidate test breaks rather than continues, and runs before the is_file()/sidecar
skip: a pattern reaching outside that matched only directories or only sidecars hit one of those
continues first, so the walk left the KB and reported nothing.
LOW — the default include is safe by luck, not by design. ["**/*.md", "**/*.txt"] does not
escape through a symlinked directory, because pathlib's recursive ** skips them. Any user who
writes a non-recursive pattern loses that, which is exactly the shape of a guarantee nobody knows
they are relying on. Stated in walk_sources' docstring rather than left as folklore.
Mutation round — three survivors, two of them defects (20260801 13:38)¶
Eleven guards broken on purpose. Eight were caught immediately. The three that survived were worth more than the eight:
HIGH — the per-root skip copied from linkscan was data loss here. sidecars_under does
if pattern in escaping: continue, so a pattern known to escape contributes nothing under any later
root — correct there, where a dropped candidate costs one inbound link and a partner's [sources]
is one statement about one KB. Copied into walk_sources it means something else entirely: the
escapes this loop can see are symlinks, which are a property of one directory rather than of
the pattern, and a dropped candidate here is a deleted index row and an orphaned sidecar. So
docs/escape -> /outside silently stopped */*.md collecting anything under an unrelated second
root. Removed, with a test. "Copy the predicate, do not re-derive it" was the right instruction and
this was still the wrong thing to copy — the predicate and the policy around it are different
decisions.
MEDIUM — the containment check ran before is_file(), and no test could tell. Every symlink
test matched a file, so moving the check after the skip changed nothing observable. The case the
ordering exists for is a pattern that matches only a directory (or only sidecars): it hits that
continue first, and the walk leaves the KB reporting nothing. */* against a symlinked directory
containing a subdirectory is that case, and it now has a test.
MEDIUM — the break bounded nothing, because sorted() had already drained the generator. The
plan carried break, not continue, on a 360× measurement from linkscan review 12 — where the
loop is lazy. Written here as for candidate in sorted(root.glob(pattern)) the enumeration a
symlinked escape triggers has already happened by the time the first candidate is inspected, so
the break saved only the loop body, and the resolved cache made even that one dict lookup. This
is the shape of a guard inherited with its justification and without the property the justification
rested on. The loop is now lazy; output order does not depend on it, because walk_sources sorts
what it returns and the per-root sort only decided which of two candidates sharing one key won —
and they describe the same file with the same hash. Measured: 301 entries enumerated before, 1
after, and both the break and a reversion to sorted() are caught by that number.
Two tooling corrections swept in the same PATCH (20260801 13:52)¶
tools/link_density_gate.py resolved one of its two bases and not the other, so any
non-canonical root — every /tmp path on macOS — exited with a traceback. One root.resolve() at
the top of census, and a test driving the tool through a symlinked parent.
tools/fragments.py's duplicate-heading defect was already closed, and the open-corrections
entry saying "the tool is unchanged" is stale. Measured rather than assumed: three fragments
(two fixed-*, one added-* whose body begins - **Fixed: …**) spliced into a section that
already had a ### Fixed produce exactly one ### Fixed, one ### Added, and the
category-prefixed entry filed by its filename, which is where the category belongs. Both halves
have regression tests already —
test_fragments_merge_into_a_category_heading_that_already_exists asserts count("### Added") == 1.
Closed by the 0.6.0 release-prep commit, not by this one.
LOW, and the reason to write this down: fragments.py --apply is anchored to the repo it lives
in, not the working directory. Testing it by cd-ing to a temp tree spliced this worktree's
CHANGELOG.md and deleted its changelog.d/ fragment, reporting success. --repo exists exactly
for that and the tool's own test suite uses it. The damage was recoverable only because the
fragment had already been committed — which is the same rule the G1 mutation harness earned:
commit before running anything that rewrites the tree. A git checkout -- to undo a mutation
in the same session then reverted an uncommitted fix in tools/, for the second time in this
project, and was caught only by re-reading git diff --stat afterwards.
The reachable-ceiling probe, against a corpus it did not ship with (20260804 04:21)¶
HIGH — a measurement tool that absorbs malformed input reports a number that looks valid. The
finding class, stated once because it generalises past this tool: every defect below is an input
the probe accepted, turned into a plausible verdict, and reported with no mark on the output. That
is strictly worse than a crash. A crash costs an hour; a number that is quietly wrong is read into
docs/STATUS.md, decides whether a schema_version bump is licensed, and is not falsifiable after
the fact — nobody re-derives a measurement that already looks fine. Anything that converts input
into a number owes its caller a refusal for input it cannot measure, and the refusal must be a
named failure, never a diagnostic line a reader has to notice.
The two found by the rehearsal that ran the probe against an external KB — both measured on
demo-kb under the offline fake backend, where the corpus reads 18 multi-hop / 9 failing / 3
liftable (the real [light] reading of the same corpus is 18 / 1 / 1, so the real-model impact of
each is several times larger):
- A hop
expectnaming a path not in the index resolved through a lookup that answered""for an unknown path, so the hop was recordedlands=False, reachable=False— failing and unreachable, identical in the output to a genuine one. One typo tookfailingfrom 9 to 10 whileliftablestayed 3. On a 200-document corpus converted by hand from a frozen question set, this is not hypothetical. - A
multi-hopquestion with nohopsincremented the denominator and produced no verdict, so it could never be countedfailingand appeared in no other figure: 18 became 19, invisibly. The scaffolded template documentedid,question,expectandkindand never mentionedhops— the trap was armed by our own template, which is why the fix edits both the tool andsrc/pinakes/templates/notes/eval/questions.yaml.
MEDIUM — the first fix was narrower than its own commit message claimed, and an adversarial pass found three more of the same class. Worth recording because the pattern is the lesson, not the individual bugs: a guard written against the two known instances validated the thing the bug report named rather than the property the measurement needs.
- A document with no chunks. The guard asked whether the path was in
documents. Every node the channel walks is built from thechunkstable, so a document with zero chunks — a blank file, a note that is only front matter, a PDF whose free extraction yielded nothing — passes a path check and is still incapable of landing or being reached. It reproduced defect 1 digit for digit (failing9 → 10,liftable3), from a path spelled correctly. "The name resolves" is not "the measurement can use it". - A
multi-hopquestion with exactly one hop, which the guard's own wording called "multi-hop in name only" and let through. This one is worse than the defect it was written for, because it movesliftableupward (3 → 4). Under-counting fails safe against a floor; over-counting licenses the schema bump. A guard on a threshold must be written in the direction that can do harm, and the harmful direction here was the one nobody had an example of. - An empty hop
query, absorbed the same way, and a golden set with nomulti-hopquestion at all, whose entirely-zero report is indistinguishable from a measured one.
MEDIUM — the second review pass found the same hole again, in the key nobody had looked at.
question.filters is applied to the last hop and was never validated. A tags, path_prefix or
source_type the index does not hold makes that hop unable to land whatever the corpus contains.
Measured on demo-kb under the fake backend, against its 9 failing / 3 liftable: one such filter
took failing to 10; the same filter across every multi-hop question took the run to 18 failing
/ 0 liftable, exit 0, unremarked. (The review pass that found it quoted only the second figure
for a single question — checking it is what caught the difference, which is the M5 lesson applied
to the fix's own write-up.) It is the empty-query defect wearing a different key, it moves both
binding clauses, and failing moves upward. Two review passes each found one more instance of a
class the first fix was supposed to have closed, which is the actual lesson: the guard has to be
written from the list of everything the measurement consumes, not from the list of bugs already
reported. What probe() consumes is now the checklist — hops, each hop's query and expect,
the document behind that path, and filters. It is validated through search's own _filter_sql
rather than a hand-written copy, so the check cannot drift from the semantics it is checking.
MEDIUM — a third pass, and the same lesson a third time: the artifact recorded every setting
except the one that moves the number most. retrieval.rerank records the mode (local), never
the reranker's provider and model — and lands is expect in the top final_k after
reranking. Demonstrated by the reviewer on one corpus, one path, one manifest, with only
[rerank] model changed: 9 failing / 3 liftable became 18 / 12, and every identifying field in the
two artifacts compared equal. Worse, the commit that added the block claimed it mirrored
eval.py::_header — which carries three blocks, embedding, rerank and retrieval, its
docstring saying it holds "every setting that can move a row". The copy took two of the three and
dropped precisely the one not derivable from the others. When you cite a prior art as the
standard you met, diff against it. index_built_at joined the payload at the same time: a corpus
edited since its last pnk sync is measured as it stood then, and nothing else would say so.
LOW, and the most human of the findings: one defect, two accusations. The filters check ran
before the hop-path check, and filters cannot admit a path the index does not hold — so a mistyped
expect under a healthy filters: block produced two problems, the first of them pointing at the
wrong line, and a {len(problems)} count that overstated. Ordering between checks is part of a
refusal's correctness, not a detail: the message that names the wrong cause costs the same debugging
hour the guard was written to save.
LOW — a sentence assembled from parts is a sentence nobody read. The per-kind wording was
spliced mid-clause into three messages, and on the branch no test covered — a non-multi-hop
question carrying hops, which load_questions allows — it rendered "so this probe never measures
this question — only multi-hop — so no figure moves for the query rather than for the corpus —
the same silent deflation as a mistyped path": two sos, and a closing clause asserting the
deflation the same sentence had just denied. The commit message claimed that wording was fixed; no
test exercised it. Each message now ends with a whole sentence, and a test covers the branch.
MEDIUM — a test can pin a claim it cannot falsify. test_the_probe_names_the_kb_it_measured
ran only --fake, and --fake measures a copy of the demo KB: every assertion in it was satisfied
by a probe that ignored --kb and hardcoded the demo path, which is the very defect the test
names. It now runs a real --kb against a KB deliberately not called demo-kb (a small runner
script registers the fake backend in the subprocess). A test whose fixture is the default cannot
detect "always reports the default".
The same mistake then repeated one layer down, and is worth recording because it is so easy to
miss twice: the replacement asserted that kb_root is resolved, using tmp_path — which is
already absolute and already resolved, so dropping the .resolve() left the entire suite green.
An assertion whose fixture already satisfies the property tests nothing. It now also runs with a
relative --kb from the corpus's own parent directory, which is the only shape that can fail.
MEDIUM — naming the corpus is not naming the measurement. The artifact recorded which KB was
measured and not what produced the numbers. failing is hop.expect in the top final_k
passages, downstream of candidates_per_source, fusion, fusion_top_k, rerank and
vector_tier — all per-KB manifest keys. Two artifacts from two configurations of the same
corpus were indistinguishable, the exact defect the KB-naming fix was written to close, one level
in. eval.py's artifact header already recorded the same set for the same reason; this one now
does too.
MEDIUM — quoting a number without its backend. The first commit message and changelog fragment
said "measured on demo-kb: failing 9 → 10" without saying the numbers were the hashing fake's;
this repository's own retrospective already records that the fake and the real models disagree
about the shape of that answer, and the real reading is 18 / 1 / 1. A user-facing fragment reads as
the real measurement. Every measured number carries the configuration that produced it, or it is
a different claim than the one intended.
MEDIUM — four passes, and the identity question kept moving outward one input at a time. Pass
one: the artifact did not name the corpus. Pass three: it named the corpus but not the pipeline.
Pass four: it named corpus and pipeline but not the golden set — the input every printed figure
is computed from, and the one this branch's own refuse-edit-re-run loop changes most often.
Demonstrated on one corpus, one index, one manifest, rewriting only the hop queries into a generic
word: 9 failing / 3 liftable became 18 / 9, and every recorded field except reports compared
equal. The payload now carries the golden set's resolved path, a sha256 of its bytes and its
counts, plus revision on both model blocks — a revision selects weights as surely as a model name
does. One correction the sixth pass earned: that "needs no re-sync, so nothing else would move with
it" is true of [rerank] revision, which nothing compares against the index, and false of
[embedding] revision, which search.check_coherence guards — change it without a re-sync and the
run stops rather than drifting. Both are recorded; only one of them could ever have moved a figure
in silence. The general form: an artifact
must identify every input its numbers are a function of, and the way to find them is to enumerate
the function's arguments, not to wait for a reviewer to name one.
LOW — the contradiction moved instead of leaving. The per-kind wording was fixed once by
appending the conditional sentence to the end, which left "the hop is recorded
failing-and-unreachable. No figure this probe prints moves" — an assertion and its denial, one
sentence apart, in a message the previous commit claimed to have fixed. The consequence is now
entirely inside the conditional (_consequence), so a non-multi-hop question is told nothing
was recorded at all, and the test asserts the class ("no line may claim a hop was recorded")
instead of one superseded string.
LOW — one more absorption, found by asking what check_measurable does not compare. Every hop
was validated on its own and never against its siblings, so two byte-identical hops passed:
MIN_HOPS satisfied, one retrieval written twice, and liftable moved from 3 to 4 on demo-kb —
upward again. A YAML copy-paste is the realistic route.
MEDIUM — five passes, and the fixture-satisfied assertion came back twice in one commit. The
pass-four commit added the golden set to the artifact and asserted it only under --fake — where
the measured golden set is demo-kb's, so hardcoding the demo path and digest passed every
assertion and the full suite. That is the identical defect pass two found for kb_root, recorded
in this very fragment as "a test whose fixture is the default cannot detect 'always reports the
default'", reintroduced by the same author two commits later for the input he had just added. The
same commit pinned revision on both model blocks against manifest.<section>.revision — and
demo-kb declares neither, so both assertions were None == None. Writing the lesson down does
not apply it: the check is mechanical — for every assertion, ask what value the fixture already
has, and whether a hardcoded constant would pass. _fake_kb now writes distinctive revisions
into its copy, and the golden-set identity is pinned on the real---kb run.
LOW — one more absorption, one normalisation short. The identical-hops check compared
(query, expect) byte-exactly, so upper-casing or padding the duplicated query defeated it while
the retrieval stayed identical — FTS5 folds case, every backend here splits on whitespace. Measured
on demo-kb: liftable 3 → 4, exit 0, the same upward move the check was written to stop. The
fingerprint is now case-folded and whitespace-collapsed. A guard on "the same input" must
normalise the way the consumer normalises, which is the _filter_sql lesson again in a smaller
key.
The fix removes the place the defect could live, not just the symptom. _doc_id is gone;
check_measurable validates the golden set against the active documents rows and the chunked
subset up front, and probe is handed the resulting map, so an unknown path has exactly one place
it can be handled and that place refuses. Validation runs before the backend loads — on a real
run that is a model download, and a run that is going to refuse should refuse in a second.
Two smaller defects of the same family. --fake silently discarded --kb, so
--kb <corpus> --fake measured demo-kb and reported its numbers as the corpus's; and neither
output format named the KB, so two runs against two corpora produced artifacts indistinguishable on
inspection — which is exactly what made the discarded --kb survivable. The pair belongs together:
a silent substitution is only dangerous because the output is anonymous, and naming the input in
the artifact is the cheapest defence a measurement tool has. The closing prose's hardcoded
>= 7 was the same error in prose form, a claim about one corpus printed under the numbers of
another; the threshold now stays with the corpus's own measurement plan.
On testing a refusal in a subprocess. These tests run the probe against a KB whose manifest
names a backend the test subprocess never registered, so a run that got past the refusal fails
too — a bare non-zero exit proves nothing. Every refusal test asserts the named message and the
offending id/path, and test_a_well_formed_golden_set_is_not_refused is the control that keeps the
message attributable to the question rather than the environment. Two assertions were weak for the
same reason and were tightened: "hops" in stderr is contained in every refusal's closing
remedy, and "--fake" in stderr is satisfied by any argparse error, since the usage line names
both flags.
One deliberate over-reach, recorded so it can be overruled. A question-level expect naming a
missing document refuses the run although probe() never reads expect — it measures hops. It
cannot move any figure the probe prints, and refusing hard-stops a corpus whose frozen question set
may not be edited. Kept because a golden set naming a document the index does not hold is broken
for make eval, which does read it, and measuring a release precondition against an unchecked
question set is not worth the saved minute — but the refusal now says which of its lines move the
count and which do not, rather than claiming they all do.
The edge census — a .get default that would have hidden its own defect (20260804 11:25)¶
MEDIUM — the first draft of the text renderer used report.edges.get(kind, 0), which is exactly
the failure class this feature exists to prevent. The whole point of edge_census is that a kind
missing from the dict must never read the same as a kind genuinely at zero. _render's first draft
indexed the dict defensively, so if edge_census ever regressed to drop a kind — a renamed key, a
kind skipped by mistake — the printed table would still show kind 0, silently correct-looking,
while the JSON output (built with dict(self.edges), no default) would be missing the key outright.
Same bug, two output formats disagreeing about whether it happened. Caught before committing, by
asking what a reviewer would ask: "would this line notice its own input being wrong?" Fixed to
report.edges[kind] — direct indexing, so a dropped kind crashes loudly in text output too, matching
JSON. The instinct to make a formatter defensive is usually right and was wrong here: this
formatter's job is to report a fact edge_census promises to supply completely, and a default that
papers over the promise being broken is worse than a crash — the same lesson
docs/RETROSPECTIVES.md's reachable-ceiling probe retro already drew about the probe's inputs,
recurring one layer up, in the probe's own output code.
HIGH — the first edge_census design counted every hub bucket, so co-located and
shared-tag could never report zero on a corpus with documents in it, which is exactly the case
the feature exists to surface. Found by an independent adversarial-review pass (a fresh agent
given the diff and the requirement, not the implementation reasoning), not by the author: a
directory holding one document, a tag on one document — plans/20260803_2239-corpus-probe-run.md
itself calls this shape "most dirs connect nothing" — still contributed its member count to the
sum, because sum(len(members) for members in hub.values()) does not care how large each bucket
is. A corpus with real documents and zero shared structure would have printed a large positive
co-located, the opposite of what the census exists to show. Fixed by excluding buckets of size
one (_spoke_count, tools/reachable_ceiling_probe.py) — a bucket with nothing else in it
derives no edge. The reviewer also showed the first reconciliation test could not have caught
this: its "independent" expectation was sum(len(members)) over the same kind of total
(document count, tag-row count), which is invariant to which bucket each item lands in — a
grouping bug and a correct grouping produce the same sum. The fixed test computes its own
per-bucket sizes from the raw tables and applies the same size-two-or-more filter, so a fixture
now needs at least one real multi-member bucket and at least one genuine singleton bucket
(docs/alone/solo.md, deliberately alone) to mean anything.
HIGH — the first reconciliation fixture could not have caught a wrong parent-child formula,
twice. 900 words replaced an initial 240: at 240 words per heading section, structural chunking
produced exactly one chunk per heading_path, so every groups[a] * groups[b] term was 1 * 1,
and a flat += 1 per group-pair passed unnoticed. Raising every group to 900 words moved every
group to size 2, not different sizes — and 2*2 == 2+2, so the next plausible wrong formula
(groups[a] + groups[b]) also passed unnoticed; the adversarial review caught this one, mutating
the multiplication to addition and finding the test still green. Fixed with three unequal group
sizes (2, 3, 5 chunks, from 800/1300/2000 words against max_tokens = 510) chosen so no pairwise
sum equals the corresponding product. The general form, twice-demonstrated: a fixture built to be
"nonzero" is not a fixture built to distinguish the correct formula from the plausible wrong ones
near it, and "I already raised the fixture size once" is not evidence the new size clears every
nearby coincidence — each candidate wrong formula has to be checked against the actual numbers
chosen, not assumed defeated by "bigger."
MEDIUM — the first authored reconciliation counted links rows; edge_census counts unique
pairs. graph.authored is a set per document, so two rels between the same two documents
(both legal — pnk link refuses only an identical (target, rel) pair, not a second relation
between the same target) still count as one edge. The first test's "independent" expectation
filtered and counted rows, which happened to agree only because the original fixture had no
document pair linked twice. Fixed by comparing unique frozenset({src, dst}) pairs instead of
rows, and the fixture now deliberately links one pair twice with different rels to make the
distinction observable.
Common thread across all three: an adversarial reviewer with no stake in the implementation asked "would this test's expectation still be right if the code grouped things differently, or if the same edge were recorded twice?" — a question the author, having just written the grouping code being tested, did not think to ask about their own logic.
The docs site — 20260804 11:55¶
Building a rendered site over docs/ was worth it for one reason before any of the styling: the
strict build is a link checker nobody had run over these files. Its first pass reported 31
failures, and they split into two classes worth keeping apart.
Twenty-three were links that leave docs/ — ../plans/…, ../CLAUDE.md,
../tools/record_claude_fixtures.py. Every one of them is correct on GitHub and dangles on the
site, because the site only has pages for docs/. These were never wrong; they became wrong the
moment a second renderer existed. All are now absolute github.com URLs, which work on both.
Eight were heading anchors, and the fix was not the obvious one. Every failure was a heading containing an em dash or an angle-bracketed code span, and the tempting repair — rewrite the eight links to whatever MkDocs generated — would have fixed the site by breaking the copy people already read in the repo. The three algorithms disagree:
| Heading | GitHub | Python-Markdown | pymdownx |
|---|---|---|---|
## 2.1 The manifest — pinakes.toml |
21-the-manifest--pinakestoml |
…-manifest-pinakestoml |
✓ |
# The sidecar — <file>.pnk.yaml |
the-sidecar--filepnkyaml |
✗ | the-sidecar--pnkyaml |
Python-Markdown collapses the whitespace the dropped em dash leaves behind; pymdownx keeps it but
strips anything angle-bracketed as an HTML tag, where GitHub slugs the escaped heading and keeps
file. mkdocs_hooks.py implements GitHub's algorithm instead, so the site matches the docs rather
than the docs being edited to match the site. The general rule: when a doc is rendered in two
places, the renderer that arrived second adapts. Anchors are a published interface.
Two headings carry an angle-bracketed code span today, which is why this is a hook and not two edits — nothing stops a third, and the next one would fail a build with no obvious cause.
Not done, deliberately: PDFScout's 01-getting-started/-style directory tree. docs/ filenames
are read by tools/fragments.py, tools/status_header_gate.py, tools/shared_file_overlap.py,
tests/test_verification.py, CI and ~150 source comments. The numbered chapters a reader sees come
from mkdocs.yml's nav labels and javascripts/section-numbering.js; the directory layout
contributes nothing to them.
Renaming the repository broke PyPI trusted publishing — 20260804 12:40¶
Renaming lucagattoni/Pinakes → lucagattoni/pinakes was checked against everything inside the
repo — 64 URLs, the docs site, CI, the gates — and all of it passed. What it broke was outside the
repo: PyPI's trusted publisher matches on the exact repository name, and it is registered on
pypi.org, where no gate here can see it.
So v0.9.0 tagged, built, smoke-tested, and was refused at the upload:
invalid-publisher — valid token, but no corresponding publisher
repository: "lucagattoni/pinakes" # the token's claim, post-rename
# the publisher still says "Pinakes"
Nothing was uploaded, which is the only reason this was recoverable: PyPI never allows
re-uploading a version, so had the rename landed between two files of the same upload the version
would have been burned and 0.9.0 would have had to be skipped. Verified against
https://pypi.org/simple/pinakes/ rather than the JSON API, which is CDN-cached and has read as
"missing" for a correct upload before (20260729).
The durable lesson: a rename's blast radius is every system that identifies this repo by name, and most of them are not in the repo. Before renaming, enumerate the external identifiers — trusted-publishing publishers, deploy keys and webhooks, any CI in another repo that clones this one, badge and package-index metadata — because a repo-wide grep proves only that the inside is consistent. GitHub's redirect for the old URL is what makes the inside look fine and hides this.
Ordering that would have avoided the outage entirely: update the external publisher first, then rename, then tag. The publisher can name a repository that does not exist yet, so there is no window where neither name works — and the reverse order guarantees one.
Interrupted sync — a TZ test that used the fixture helper would have proven nothing (20260804 12:46)¶
HIGH — the first draft of the UTC-timestamp regression test called test_sync.py's own run()
helper, which hardcodes now="20260725 16:00" for every other test in the file specifically to
bypass the real clock. sync.py:709's stamp = now or datetime.now(UTC)... only reaches the
real-clock branch when the caller passes now=None — and run(kb, ...) never does, because that
fixed string is what makes 60-odd other tests in test_sync.py deterministic. A TZ test built on
top of run() would set TZ, call run(kb), read back meta['built_at'], and find it exactly
"20260725 16:00" regardless of which clock sync.py actually used — passing identically whether
the site under test read datetime.now() or datetime.now(UTC), because neither ever ran. Caught
before committing by asking the same question the increment's own instructions insist on for the
mutation pass: does the assertion distinguish the fixed from the broken code, or only look like it
does? Fixed by calling sync() directly with no now= override in both TZ tests
(test_a_real_sync_stamps_utc_not_local_under_a_non_utc_timezone,
test_estimate_only_stamps_utc_not_local_under_a_non_utc_timezone — the second reaches its own
independent clock at sync.py's _estimate_only, unaffected by the outer now= either way, so it
needed no such change but is named here for the record) — and mutation-verified afterward by
reverting each site to datetime.now() and confirming the corresponding test failed by roughly the
test's chosen offset (Pacific/Kiritimati, UTC+14), not merely failing.
The general rule this confirms rather than discovers: a test helper that fixes an input to make
most tests deterministic is exactly the place a new test targeting that specific input must not
reuse the helper — the fixture built to remove non-determinism from one property will just as
readily remove the property a new test exists to observe. docs/RETROSPECTIVES.md's own advice on
claiming a test is mutation-verified — run the mutant, don't just trust the assertion reads
correctly — is what caught this one before the mutation pass would have had to.
The progress printer's closing newline assumed the loop always reaches its own end (20260804 13:07)¶
MEDIUM — an independent adversarial review found that cli._progress_printer's "finished" branch
(done >= total) is the only place the printer ever emits its closing newline, and _run's loop
(sync.py) does not always reach done == total. A [budget] cap or any early exit stops the
loop partway through, so the last progress(done, total) call for that run has done < total, and
the printer's \r-prefixed line is left open — no trailing newline — for whatever prints next
(print_sync_report, or an error message on an unhandled exception) to land on. Confirmed live: a
progress line followed immediately by report text on the same terminal row. No test in the original
commit exercised done < total as a run's last call — test_progress_printer_throttles_...
always ended at done == total, and the progress-callback sync test never triggered an early stop.
Fixed by splitting _progress_printer() into (progress, finish): progress behaves as before,
but also tracks whether a line is open (dirty); finish closes it with one newline if so, and is
a no-op otherwise. run_sync calls finish unconditionally in a finally around the sync()
call, so it closes the line whether the run finished normally, stopped on a budget cap, or sync()
raised. Mutation-verified: made finish() unconditionally clear dirty without printing;
test_progress_printer_finish_closes_a_line_an_early_stop_left_open caught it (expected "\n",
got "").
The general shape of the miss: a "does this print the right thing" test that only drives the
happy path (the loop's last call always being its final call) cannot see a defect that only
exists on an early-exit path, because the assertion and the code under test share the same
unstated assumption — "the loop reaches done == total" was never itself questioned, only how the
printer behaves once it does.
A green ./check.sh only proves the worktree's own venv is green (20260804 13:30)¶
MEDIUM — test_estimate_only_stamps_utc_not_local_under_a_non_utc_timezone shipped with no
pinakes[pdf] skip marker, and ./check.sh was green anyway, because this worktree had [pdf]
installed. The test writes a real baseline-1p.pdf and calls page_count on it for real (its
own docstring already said so), which needs pypdfium2. The planner's worktree did not have the
extra installed and hit AttributeError: module 'pypdfium2' has no attribute 'PdfDocument' at
merge time — same commit, same script, different venv. CI's check job runs a three-leg matrix
over [light], [light,pdf] and [light,pdf,claude] specifically because core stays torch- and
pypdfium2-free by design (CLAUDE.md § Tooling); this would have gone red on the [light] leg
after merge, which the merge-time worktree-mismatch is what actually caught here.
The rule this earns: a green ./check.sh run proves the worktree's own dependency set is
green, not the matrix — pytest silently skips whatever the installed extras cannot exercise
rather than failing loudly, so a test that forgot its @pytest.mark.skipif(not
pdf_extraction_runnable(), ...) marker doesn't skip or fail locally when the extra happens to be
present; it just runs, passes, and says nothing about the leg where it can't. Before landing any
test that touches a PDF fixture, the claude extractor, or a real embedding backend
(sentence-transformers/fastembed), check which pdf_runnable()/pdf_extraction_runnable()/
paid_runnable() predicate (tests/conftest.py) it needs and mark it — and separately, run at
least the affected file with the relevant extra uninstalled (uv sync --extra light --frozen,
run, then uv sync --extra light --extra pdf --extra claude --frozen to restore), because that is
the only way ./check.sh passing locally actually predicts the [light] CI leg rather than just
restating the worktree it ran in.
G3 — the node model and the edge set (20260804 16:30)¶
HIGH — a hierarchy derivation that was quadratic in a document's chunk count. parent-child
was derived by testing child.heading_path.startswith(parent.heading_path + " > ") over every
chunk pair within a document. Measured on one document: 2 000 chunks 0.23 s, 4 000 chunks 0.85 s,
8 000 chunks 3.32 s — so a single 32 000-chunk document (one long PDF) would have spent ~50 s
deriving, on a path pnk sync runs from three git hooks. Replaced by grouping chunks by heading
path and having each path look up its own ancestors (" > ".join(segments[:d])), which is the same
relation and is linear in chunks: 32 000 chunks now 0.59 s. The corpus that would have exposed
this is the one that cannot: the RFC realism corpus has an empty heading_path on every chunk, so
its 106 806 chunks derived zero hierarchy edges and cost nothing. A performance defect invisible on
the only corpus at scale.
HIGH — a mutant that nothing caught, in a filter that looked redundant. authored_pairs
filters links on src_kb_id = ? AND dst_kb_id = ? and then joins both ends to doc nodes.
Changing the AND to an OR failed no test, because the join already drops a foreign document
ULID — a foreign document has no local doc node. That reasoning is wrong in exactly one case, and
it is a case that happens: fork a KB — copy the directory, mint a new [kb] id, and every
document keeps its permanent ULID. A reverse scan of the fork then writes
(fork_kb, D, local_kb, E) where D is also one of our documents, and the OR reads it as "our
D cites E" — an edge nobody authored here. The lesson generalises: a filter that a second filter
appears to make redundant is only redundant under an assumption, and the assumption is the thing to
test. Pinned by test_a_forked_kb_sharing_a_document_ulid_does_not_forge_a_local_authored_edge,
which builds two real KBs rather than inserting the row.
MEDIUM — the deriver was cross-checked against the instrument the go decision was measured on,
and this was worth more than any single test. tools/reachable_ceiling_probe.py derives the same
relations in memory, written independently. Comparing the two censuses on tests/demo-kb
(test_the_stored_edge_set_agrees_with_the_probe_the_decision_was_taken_on) caught two mutants no
targeted test did, and the RFC corpus reproduced the go decision's drop table exactly — sibling
106 506, shared-tag 643, co-located 262. A second implementation of the same spec is a cheaper
oracle than a third round of hand-written assertions, and it answers a question no assertion can:
did G3 build the graph G2 measured, or a different plausible one.
MEDIUM — the plan's orientation rule, read literally, makes every hub unreachable. G3's spec
says the provider queries "src = ? OR dst = ? for those kinds and src = ? for hub kinds". A hub
spoke is stored hub-first, so src = ? answers "who is in me" — and a member asking "what am I in"
needs dst = ?. Read literally, no member could ever enter a hub, and co-located/shared-tag
are the two kinds the go decision measured carrying all nine liftable questions. The sentence's real
content is the symmetric half: a src-only read of a symmetric kind silently drops half of every
relation. Built as three explicit functions — peers(), members(), hubs() — so the two halves
of a hub kind cannot be confused for one query, and pinned by
test_a_hub_is_entered_from_a_member_and_expanded_from_the_hub.
HIGH — a kind selection that could be silently dropped, in the function that preaches against
it. peers()/neighbours() took local_kb: str | None = None and skipped authored when it was
absent — the same "confident, wrong, smaller answer" its own docstring warns about for a src-only
read, from the other side. G5's gate runs with and without authored edges; a caller who forgot
the keyword would have got the "without" arm believing it ran the "with" one, and lost the
highest-trust edge class with nothing printed. Now a TraversalError naming
select_kinds(drop=["authored"]) as the way to mean it. select_kinds() refusing an unknown name
was designed against exactly this and did not cover it — the refusal guarded the name and left
the ingredient.
MEDIUM — every corpus that certifies this increment has two of the six kinds at zero.
parent-child has derived zero edges in every measurement ever taken here — one-level headings in
both committed corpora, an empty heading_path throughout the RFC corpus — and no committed sidecar
carries a tags: key, so shared-tag is exercised only by synthetic fixtures. The cross-check
against the probe therefore passed with _hierarchy_edges deleted, because its non-vacuity guard
was a total over all kinds. Fixed by asserting each compared kind is non-zero and naming the two
this corpus cannot exercise, so a kind that silently stops deriving fails rather than reads as
agreement. A "total > 0" guard on a per-item comparison is not a non-vacuity guard — it is one
item's evidence spread over all of them.
MEDIUM — parent-child is materialised pairwise, and its row count is the product of two
sections. APPROACH §3 keeps hierarchy direct — "the one relation that stays direct" — so an
ancestor heading of a chunks and a descendant of d chunks is a·d rows. Measured at
max_tokens=400 on plausible document shapes: 5.8×, 16.3× and 53.5× the chunk count. Extrapolated
to 300 documents of the worst shape that is ~10 M rows. Not changed here — hubbing it contradicts
APPROACH §3 and restricting it to the immediate parent narrows the relation the go decision's probe
measured — but pinned by a test that asserts the arity, and reported to the planner as a spec
question rather than absorbed as an implementation choice.
LOW — a duplicated tag inflated a hub's member count where nothing could see it. derive
collects edges into a set, so tags: [t, t] produced one spoke — but _tag_buckets appended the
document twice, and the < 2 minting rule counts the bucket. A single document repeating one tag
therefore minted a hub with one spoke and a divisor of 2. The test named
test_a_duplicate_tag_in_one_sidecar_is_one_spoke passed with the deduplication removed, because
the set downstream hid the mechanism it named. Deduplicated where the length is decided, and pinned
by the single-document case that has no set to hide behind.
LOW — a hub with one member is derived state that connects nothing. A directory holding one
document, a tag on one document, a heading with one chunk: expanding it returns only the node that
reached it. The spec only says degree-zero hubs are reaped, which full re-derivation gives for free.
Degree-one hubs are minted at zero benefit — a node, a spoke, and an entry in G6's hub report — so
they are skipped, which also makes the census directly comparable to the probe's (_spoke_count
counts buckets of two or more). Reachability is unchanged for the channel as APPROACH §4A defines
it today; §4B's all-chunk seeding is itself flagged for re-evaluation on the sqlite-vec tier, so
the claim is scoped rather than unconditional. The alternative reading is recorded here because it
was a choice, not a deduction.
G5 — the parent-child ceiling, measured before the gate ran (20260804 21:05)¶
The measurement the arity decision required, and the corpus that could supply it was neither of
the two anyone would have reached for. plans/20260804_1844-decision-parent-child-arity.md
keeps parent-child transitive and asks for a ceiling "against a corpus whose chunker actually
populates heading_path" — because the projection of 5.8×–53.5× the chunk count had never been
run against one. Both obvious candidates fail for different reasons, and only the second was
known:
| corpus | documents | chunks | carry a heading_path |
heading depth | parent-child |
|---|---|---|---|---|---|
| RFC realism | 300 | 106 806 | 0 | — | 0 — structural chunking degraded silently |
tests/demo-kb |
30 | 60 | 60 | always 1 | 0 — every document is flat |
this repo's docs/ + plans/ |
43 | 2 671 | 2 671 | median 2, max 4 | 13 232 |
tests/demo-kb populates heading_path on every chunk and still derives zero hierarchy edges,
because a depth-1 path has no ancestor. A corpus can satisfy "the chunker works" and still
exercise nothing, which is a second way to get a zero that looks like a measurement — and it is
the shape the go decision's own bound warns about, one layer in.
The numbers¶
Real Markdown, written by hand, with real nesting — 43 of this repository's own documents, indexed
into a scratch KB (never committed; docs/ and plans/ are the corpus):
- 4.95
parent-childrows per chunk — 13 232 over 2 671 chunks. Againstsibling's 2 628 andin-section's 2 509, hierarchy is 71% of every stored edge, on a corpus wheresiblingis one row per adjacent chunk. It lands below the 5.8× floor the decision projected, so the projection was pessimistic rather than optimistic. - Derivation costs nothing. The whole edge set derives in 0.158 s; the hierarchy alone is 0.004 s for those 13 232 rows. G3's ancestor-lookup form is linear in chunks and quadratic only in a document's distinct heading paths (median 11 here, max 76), exactly as it claims. The cost is row count, never wall clock, which is what the decision predicted and is worth stating because it changes which mitigation would ever be needed.
- Index growth is 12.9% — 11.17 MB with the hierarchy against 9.89 MB without, both
VACUUMed. On a corpus of this shape the absolute number is 1.2 MB, and it scales with rows, not bytes of text: an index dominated by 384-dimensional embeddings (the RFC corpus is 265 MB for 106 806 chunks) would grow proportionally far less.
The ceiling is not alarming, and the standing risk is unchanged¶
Extrapolating 4.95 rows/chunk to the RFC corpus's 106 806 chunks gives ~529 000 hierarchy rows against its 107 802 total today — a five-fold graph, derived in well under a second. That is a number, not a problem, and it does not license switching to the immediate-parent variant: the decision is explicit that the variant is the arm to measure if the ceiling proves alarming, never the default to switch to first.
What is still unmeasured is the tail. 4.95 is a mean over documents whose median depth is 2.
The decision's standing risk — "a corpus with deep heading nesting and large sections could make
parent-child the dominant kind" — is about a shape none of these three corpora has: deep
nesting and many chunks per section, where the row count is the product. This corpus's worst
document carries 76 distinct heading paths and its arity stays modest because its sections are
short. Nothing here refutes that risk; it bounds the ordinary case and leaves the tail where the
decision left it.
The tail, measured (20260804 22:39) — and it is alarming¶
The paragraph above left the standing risk as prose. It is now a number. A purpose-built worst-shape corpus — six documents, heading depth 4, every heading path carrying ~26 chunks, which is the a·d product the risk names — was generated, synced with the same real backend, and measured the same way:
this repo's docs/ + plans/ |
worst-shape corpus | |
|---|---|---|
| chunks | 2 671 | 2 483 |
| heading depth, median / max | 2 / 4 | 3 / 4 |
| chunks per heading path, median | short sections | 26 |
parent-child rows |
13 232 | 132 630 |
| rows per chunk | 4.95 | 53.42 |
| share of every stored edge | 71% | 94.7% |
| index growth | +12.9% (1.2 MB) | +113.4% (13.3 MB) |
| derivation | 0.004 s (hierarchy) | 0.84 s (140 079 edges, every kind) |
53.42 lands at the very top of the decision's projected 5.8×–53.5× band, so the projection was accurate at both ends rather than pessimistic: 4.95 sits below its floor for ordinary prose, 53.4 reaches its ceiling for the shape it warned about. The index more than doubles. Derivation stays cheap — 140 079 edges in 0.84 s — which confirms the decision's own prediction that the cost is row count, never wall clock, and therefore that no mitigation aimed at derivation time would help.
What this corpus is, and is not. Synthetic and deliberately adversarial: generated Markdown with uniform nesting and uniform section length, built to make the product as large as plausible rather than to resemble anyone's notes. It is not evidence that real corpora do this — neither real corpus measured above comes close. It is evidence that the shape is reachable without anything exotic, because depth 4 with long sections is an ordinary specification or manual.
No variant is switched to here, on purpose.
plans/20260804_1844-decision-parent-child-arity.md
is explicit that if the ceiling proves alarming the immediate-parent form is the arm to measure,
never the default to change first — and --drop parent-child is already a reported leg of G5's
matrix. This is the input to that decision; the decision is the planner's.
G5 — the expansion channel (20260804 21:35)¶
HIGH — a ranking rule that would have made depth 2 unreachable, and it read as the careful
choice. The channel's output is ordered and then cut at candidates_per_source. The first
implementation ranked (distance, -cosine, …), on the reasoning that a two-hop chunk should not
outrank a one-hop one on similarity alone — which sounds like the conservative reading of APPROACH
§4A's "score expanded chunks by edge weight and link distance". It is not conservative, it is
silently disabling half the feature: with distance as the primary key, every one-hop chunk
precedes every two-hop one, so on any corpus where one hop already fills the cut, depth 2
contributes nothing to the output at all. The channel would have been depth-1 wearing a depth-2
budget — and the reachability ceiling that unblocked this increment was measured at two logical
hops, so the gate would have been measuring something the precondition never covered. Now
(-cosine, distance, …): cosine ranks, distance breaks the ties cosine cannot, and
test_a_two_hop_chunk_outranks_a_one_hop_one_when_the_query_says_so fails if the order comes back.
HIGH — every tiebreak resolved to a surrogate id, and it looked stable because of another
module's ORDER BY. Fan-out, frontier order and the final ranking all broke ties on
nodes.id. Those ids are deterministic today — but only because edges.derive enumerates
documents ORDER BY path — so the channel's answers depended on an invariant of a different
module, unstated in both. That is G1's defect in a new place: _hydrate's unordered
WHERE c.id IN (…) was stable in practice too, until a rebuild moved one golden-set question.
Every tiebreak here now resolves to (documents.path, chunks.ordinal), the total order G1 gave the
rest of the pipeline. The lesson is not "avoid surrogate ids" — it is that "this is
deterministic" is a claim about the code that computes it, and if that code is somewhere else, the
determinism is an assumption rather than a property.
HIGH — a fixture that was not the shape its own docstring described, in the tests written to
catch exactly that. The membership-exclusion fixture built one document of six flat sections, so
that ordinal 0 and ordinal 4 would be reachable only through their document's membership edge.
Its body was f"## Section {i}\n\nword{i} " * 30 — which repeats the heading thirty times, and
produced 180 chunks nested under Section 0 > Section 5. A parent-child hierarchy, inside a
fixture whose entire purpose was to have none, in a file whose docstring names "an assertion
satisfied by something other than the property it names" as the failure it is written against. It
was invisible until an unrelated ordering change made the walk reach one chunk more. Two things
followed: dropping the # Title was not enough either — the chunker then reads the first ##
as the root and derives Section 0 > Section n, the same defect one heading level down — and a
fixture that a test's meaning depends on now asserts its own shape (_assert_flat_sections: six
chunks, every heading path depth 1, zero parent-child edges). A fixture is an assertion.
MEDIUM — a mutant that survived eighteen others, in the half of a rule the other half hides.
The membership exclusion is two filters: a document never passes through to itself, and a root's
own document never contributes member chunks at any depth. Deleting the second left the whole
suite green, because at hop 1 the source is the root's document, so the first already covers
it — and no fixture ever reached a root document from somewhere else. The shape that does is
A —authored→ B —authored→ {A, C} at adjacent_k=1: at hop 2 the source is B, the first filter
does not apply, and without the second, A takes the only slot to re-contribute chunks the query
already had. Two rules that agree on every case you have built are one rule until you build the
case that separates them.
LOW — a throwaway mutation harness produced two runs of contradictory results. Mutating a
source file, running pytest and restoring in a loop reported extra failures in tests unrelated to
the mutated file, twice, while the machine was also running a two-hour re-index and an eval matrix.
Clearing __pycache__ between mutants and running each mutant on a quiet machine gave seven clean,
reproducible results. The mechanism was not pinned down — a stale bytecode cache after a
same-second restore and a subprocess that failed to spawn under load are both consistent with what
was seen. Recorded because the reaction is the reusable part: a mutation result that implicates a
file the mutant did not touch is a result about the harness, and re-running it on a quiet machine
costs a minute.
G5 — a root spends a fan-out slot it is then discarded from (20260804 22:30)¶
HIGH — the fourth review round, on a rule the third round had already written down. Round three
added a filter to _offer_chunks dropping candidates already found this hop or already emitted by
an earlier one, with the reasoning stated in the comment: "a slot spent on it adds nothing, and
dropping it before the cut is the same rule the membership exclusion applies one level up". The
rule is right. It was applied to two of the three categories it covers.
A root reaching found is discarded twice over. _accept skips it before emitting, and run
seeds self._expanded with the roots so it never joins the frontier either — so a root contributes
neither a row nor a hop. Yet it had already taken one of the adjacent_k slots on the way, and the
neighbours of a fused top-k chunk are very often other fused top-k chunks: sibling connects
adjacent ordinals, and adjacent ordinals of a chunk the query matched are exactly what the vector
stage also ranked highly.
Measured on graded_neighbour at the shipped default adjacent_k = 8: 4 candidates returned as
built, 10 with roots dropped before the cut — and all six of the new ones are chunks fusion had
not found, which is the only thing the channel exists to contribute.
Why it survived three rounds. Every existing assertion about roots was set-level — "neither
root may be emitted", "expansion still runs" — and a set-level assertion cannot tell a slot
spent from a slot saved. Both are satisfied by the defective walk. The test that catches it counts
instead: adjacent_k = 1, two candidates, one of them a root, and the single slot must reach the
non-root. With the filter removed the walk returns [], which is the failure mode named in the
assertion's own message.
What the fix is worth, and where that cannot be seen¶
tests/demo-kb moved zero questions across all three gate legs — off, expand,
expand-no-authored — with aggregates identical to four decimal places. That is not evidence the
fix is inert: demo-kb's documents are about two chunks each, so adjacent_k = 8 never saturates
and a root never displaces anything. A corpus can be incapable of exercising a change, and
reporting "no movement" from one is reporting the corpus, not the code. The RFC realism corpus —
300 documents, 106 806 chunks — is where the fan-out cut actually binds, and it is the corpus the
gate runs on.
The backstop that now survives mutation, on purpose¶
_accept's if node in self._roots: continue is unreachable once _offer_chunks filters. It
stays: "a root is never emitted" is an invariant of the emit point, and a later caller adding a
second way into found should not be able to break it from a distance. The honest consequence is
recorded in the docstring and in the mutation table — deleting that line fails no test, while
deleting the _offer_chunks filter fails exactly one. A backstop whose mutant survives is fine;
a backstop silently counted as the enforcement is not, which is the same
assertion-satisfied-by-something-else failure in its bookkeeping form.
G5 — the gate, run: expand ships off (20260804 22:52)¶
HIGH — the increment's deliverable is a negative measurement, and it is a clean one. On the RFC realism corpus, at G5's own HEAD, against a schema-3 index rebuilt for this run:
| leg | multi-hop | improved | regressed | p |
|---|---|---|---|---|
off |
7/20 | — | — | — |
expand, drop authored |
4/20 | 0 | 3 | 1.0000 |
expand, all kinds |
4/20 | 0 | 3 | 1.0000 |
licensing p = 1.0000 (max, the more conservative of the two). expand defaults off, and
tools/graph_gate.py exits 1. Clause 1 fails in both runs; clause 2 fails as well —
by_kind[multi-hop] 0.350 → 0.200 against a 0.02 tolerance — and clause 4 fails on recall_at_k
for the same movement.
Lost: content-disposition-http-takeover (hit at rank 1 → not found at all),
http-rate-limit-status-code-provenance (rank 4 → miss), sip-invite-2xx-retransmission-defect
(rank 2 → miss). Nothing was lifted.
reachable ≠ retrievable — the finding, and the plan predicted its shape¶
The reachability probe found 9 of the failing multi-hop questions reachable within two logical hops without authored edges, which is what unblocked the graph release. The retrieval instrument lifts none of them, and the channel's extra candidates displace three answers two-list fusion already had. The plan drew this distinction itself — "a ceiling gauge cannot rank, and an argument cannot measure" — and G5's whole existence is the reason it could be checked rather than assumed. A reachability precondition is necessary and nowhere near sufficient, and the gap between the two is not a small correction: it is 9 questions against 0.
The go decision also anticipated the outcome's meaning: "If expand did not pass, the finding is
'graph structure does not help this corpus', and the response is a corpus or a different channel
design, never an escalation to a more expensive one." Nothing here licenses PPR.
Two things that make the number narrower than it looks, both stated rather than worked around¶
Clause 3 passes vacuously, and clause 4 nearly so. The RFC corpus has [retrieval.confidence]
commented out, so every question scores confidence: unknown; neither the confidence-lost nor the
newly-found-at-low term can be non-zero there. The decomposition clause 3 exists for is exercised
only by the synthetic artifacts in tests/test_graph_channel.py — which is precisely why the plan
insisted the gate be driven by synthetic fixtures as well as by the corpus. A gate whose only
fixture is the real corpus can only be tested in whichever direction that corpus happens to point,
and here two of its four clauses would never have fired.
The --drop parent-child arm is inert on the gating corpus. No chunk in the RFC corpus carries
a heading_path, so parent-child and in-section derive zero edges and dropping them changes
nothing by construction. The arm the arity decision added cannot say anything here. Its cost was
measured separately (see the ceiling fragment); its retrieval value remains unmeasured, and the
corpus that could measure it does not exist yet.
The --drop sibling arm answers its question, and the answer is "neither"¶
The go decision added this arm to ask, with the instrument that measures retrieval quality rather
than a reachability ceiling, whether 99.2% of the graph's mass earns its place. On this corpus
sibling is 106 506 of the 107 411 non-transit structural edges, and dropping it produces
exactly the same 4/20, the same three regressions, and the same p = 1.0000 — one question's
rank moves, and it is a miss either way.
So sibling neither helps nor hurts. It is 99.2% of the stored graph and it is inert in both
gauges: the reachability probe already found removing it cost nothing, and the retrieval
instrument now agrees. Two independent measurements, and the harm the channel does comes from
somewhere else entirely — the document-level path, membership transit into co-located (262
edges) and shared-tag (643) hubs, which pull whole documents' chunks into the fusion.
With the caveat that makes it a narrower claim than it looks: every chunk here has an empty
heading_path, so a "sibling" in this corpus is an adjacent arbitrary size-slice, not an adjacent
section. The arm has measured the value of size-slice adjacency, which is what this corpus's broken
structural chunking produced — not the value of sibling as designed. On a corpus whose chunker
works the question is still open.
Five legs land on the same number; the sixth does not, and I had already written that they all did¶
The full matrix, off at 7/20 (recall@k 0.3500, MRR 0.421):
| leg | multi-hop | improved | regressed | p | ms/query |
|---|---|---|---|---|---|
off |
7/20 | — | — | — | 2012 |
expand |
4/20 | 0 | 3 | 1.0000 | 2051 |
expand-no-authored |
4/20 | 0 | 3 | 1.0000 | 2106 |
expand-no-sibling |
4/20 | 0 | 3 | 1.0000 | 2067 |
expand-no-parent-child |
4/20 | 0 | 3 | 1.0000 | 2028 |
expand-no-link-distance |
4/20 | 0 | 3 | 1.0000 | 2024 |
expand-in-degree |
6/20 | 1 | 2 | 0.8750 | 2237 |
MEDIUM — the process finding, and it is the file's own failure class caught in the act. With
five of the six legs written I recorded that "every leg lands on the same number, which is what
makes the result robust" — a claim about six legs asserted from five, while the sixth was still
running. The sixth then came back different: in-degree salience is the only configuration that
lifts anything (imap-utf8-two-strategies), and the only one that regresses two rather than three.
The claim was wrong within minutes of being written, in a fragment whose subject is an assertion
satisfied by something other than the property it names. A generalisation over N runs written
while N−1 have finished is not a measurement, it is a prediction wearing a measurement's clothes.
The correction is recorded rather than quietly overwritten, because the tempting fix — waiting and
writing the true sentence — would have hidden how easy it was to write the false one.
What the sixth leg does and does not license. expand-in-degree at 1 improved / 2 regressed is
p = 0.8750: nowhere near the gate, and still a net loss of one question against off. It is
reported, never gated — three variables against one threshold is not a decision procedure — and
noticing that it is the best-performing leg after seeing the numbers is exactly the exploratory
fitting the pre-commitment forbids. It is a direction for a future measurement on a corpus that can
carry one, not a result.
Latency, the other exit criterion. off 2012 ms/query against expand 2051 — 1.02×, so the
channel costs about 2% on a 106 806-chunk index and the "slow at query time" risk did not
materialise. In-degree salience is the expensive leg at 2237 ms (1.11×), which is the one place the
matrix's timing separates the configurations at all.
The with/without-authored split is stronger here than on tests/demo-kb¶
All 391 of the corpus's authored links are intra-KB — every to: names the corpus's own KB
ULID — so unlike tests/demo-kb, where only 12 of 16 survive the cross-KB inertness rule, the
with-authored leg has every one in play. They still move only two questions' ranks and flip no
outcome, and both gated runs regress the identical three questions. The anti-circularity guard had
nothing to catch, because there was no win to be circular about.
The pre-commitment held¶
A result short of the table ships the channel off, with counts and p-value recorded, untuned.
Nothing was tuned, no weight moved, no threshold was revisited after seeing the number. The
authored weight's measured at G5 marker is discharged by this run as "measured, and it changed
no outcome" rather than by a fitted value.
G6 — Edge-hub reporting (20260805 06:20)¶
MEDIUM — the enumeration order this check's own sort relied on was implicit. The first cut
enumerated hub node ids with SELECT DISTINCT src FROM edges WHERE kind = ?, no ORDER BY — the
only query touching edges in this codebase without one; every read in graph/edges.py orders
explicitly, and that file's own docstring argues for it. It happened to come back in ascending
src order today (a covering-index scan on edges_src(src, kind)), which is exactly the "mint
order" the first test's fixture needed to differ from degree order to prove the .sort() was doing
real work. That property held by accident of a query plan, not by anything this function asserted.
Fixed with an explicit ORDER BY src. Found by an adversarial review agent, not by any test — no
test could have caught it, since the property under test (the sort) still worked; only reading the
query against the file's own convention surfaced it.
MEDIUM — one of the three hub kinds shipped with zero assertions on its printed text. _hub_label
has three branches — tag, dir, heading — and the landing commit tested tag (the sort-order
fixture) and heading (the human-actionable-label fixture) but never dir. co-located appeared
in a docstring only, explaining why the other fixtures were built to avoid triggering it — which
reads, on a second look, exactly like the tests were routed around covering it rather than covering
it. test_a_directory_hub_is_named_by_its_kb_root_relative_path closes it.
LOW, folded into the fix above — a degree tie had no assertion in either direction. top.sort(key=
lambda item: item[1], reverse=True) is a stable sort, so two hubs at equal degree kept whatever
order they arrived in — which is to say, the same implicit query-plan order the first finding
flags, one layer further in. Given a real tie the printed order was accidental twice over. Fixed by
sorting on (-degree, kind, key) explicitly, and test_a_degree_tie_breaks_deterministically_and_
the_rest_are_counted builds four hubs tied at degree 2 whose mint order is the reverse of their
correct printed order — reverting the tiebreak makes the mutant print d, c, b instead of
a, b, c, which is the sharpest test in this increment: it fails on a reverse=True-only sort
that every other test here would still pass.
LOW, self-inflicted — a vacuous assertion, written by the implementer, in this same increment.
While extending an existing test's block with a new line, an assertion checking for a string that
appears nowhere in src/ — "unchecked until the links release" not in detail — was added twice
(once by the edit that introduced the increment's other tests, once copied into a third test written
later in the same session). It always passed, proved nothing, and is exactly the failure class this
project's own CLAUDE.md names: "an assertion satisfied by something other than the property it
names." Caught by cross-referencing the diff against src/ during this same retrospective pass
(grep -rn "unchecked until the links release" src/ returns nothing), not by any tool — nothing
in check.sh can distinguish a true-but-empty assertion from a load-bearing one. Removed.
Read together: three of these four findings trace to the same root — an implicit order (query
plan, sort stability) standing in for an explicit one, discovered because it happened to agree
with what a fixture needed. G3's own docstrings warn about exactly this shape for a src-only read
of a symmetric edge kind ("a confident, wrong, smaller answer"); this increment reproduced a milder
version of it one level up, in the read that reports G3's own structure back to a human.
The [light] backend error — a fixed test that only looked environment-independent (20260805 07:41)¶
MEDIUM — the existing test_a_missing_extra_names_the_install_command was silently coupled to
which extras happen to be installed in the checkout running it, and this dev checkout already has
fastembed (a transitive dependency of some other extra) even without [light] explicitly
requested. Once BackendMissingError started naming an installed alternative, that test's
monkeypatch.setattr(builtins, "__import__", refuse) — which only blocks sentence_transformers —
left fastembed genuinely importable, so load_backend picked it up as the alternative and the
old assertion ('uv add "pinakes[st]"' in remedy) started failing for the right reason: the new
code path executing, not a bug. A version of this test that had merely added the new assertions
without also forcing "nothing else is installed" would have been true by luck of this machine's
site-packages, not by construction, and would flip on a bare CI leg or a machine with no
fastembed at all. Fixed by monkeypatching importlib.util.find_spec directly in both the
"no alternative" and "alternative present" tests, so each names its precondition instead of
inheriting whatever the environment happens to have — the same discipline docs/RETROSPECTIVES.md
already names for tests that read like they exercise a real-clock or real-install branch but
route around it.
Confirmed by the mutation pass: forcing _installed_alternative's find_spec check to always
report "installed" broke test_a_missing_extra_names_the_install_command specifically (asserting
alternative is None), and reverting _import to drop the detected alternative broke both
alternative-path tests specifically (asserting alternative == FASTEMBED) — each mutation failed
the test that names the property it broke, not an unrelated one.
The heading-coverage check — two findings, one of them about my own test¶
HIGH — the open correction's diagnosis was approximately right and precisely wrong, and the
difference decides the fix. Item 1 read "the heading grammar is Markdown-shaped; RFC section
numbering is not, so nothing matches and the strategy quietly becomes size-based". That describes a
regex being tried and failing. What actually happens is chunk.py:131:
_markdown_blocks is never called for a .txt file, and _plain_blocks sets
heading_path=None unconditionally. Nothing failed to match because nothing was tried. The
consequence for the fix is not cosmetic: tightening or extending a regex would have changed nothing,
and the real change is adding heading detection to a code path that has none. It also bounds the
blast radius in the useful direction — tests/demo-kb is Markdown, so a plain-text grammar cannot
move the golden set, and "changing chunking needs eval justification" becomes a thing you can
prove rather than argue.
A measurement replaced a threshold. Chunking the committed corpora directly — no index, no
embeddings, chunk_document called in a loop — gave demo-kb 60/60 and partner-kb 55/55 at 100%
against the RFC corpus's 0%. Bimodal, so the predicate can be "zero for this source type" and the
check carries no constant anybody had to calibrate. The alternative, a fitted percentage floor,
would have needed a corpus to fit against and would have fired on ordinary documents whose opening
paragraph precedes their first heading.
MEDIUM — mutation testing refuted one of my own tests within a minute of writing it. I wrote
test_heading_coverage_counts_only_active_documents, asserting the state = 'active' filter. M1
deleted the filter and the test stayed green. The reason is that SoftDelete drops a document's
chunks as well as flipping its state, so a chunk-counting query has nothing to over-count either
way — unlike _links, which counts documents and genuinely needs the filter it records having
shipped without (2 of 1 documents linked (200%)).
The test was kept and renamed to what it proves (test_a_removed_documents_chunks_stop_being_counted),
with the refutation written into its docstring so nobody re-derives the wrong claim from the old
name. The filter stays as defensive consistency with _links, marked as unreachable by this
fixture rather than presented as guarded.
This is the file's own recurring failure class caught in the act: an assertion satisfied by something other than the property it names. Three of the four mutants died as intended (the zero-per-type predicate, the two-cause remedy split, the WARN status); the one that survived was the one whose name made the strongest claim. Green proved the test ran; only the mutant proved what it could detect — and for one of five, the answer was "not the thing in its name".
Item 5 — doctor never prints the operator's home directory (20260805 08:06)¶
LOW — two residual home-directory leaks exist, both correctly out of this increment's scope.
An adversarial review confirmed the sweep of src/pinakes/doctor.py is exhaustive against every
PinakesError subclass doctor.py forwards, and found no defect in the fix itself (three
independent mutations — a no-op _de_homed, a swapped _local tuple order, and a reverted
_sidecars call site — each broke exactly the test that should catch it, and nothing else). Two
things remain that print an absolute path containing the operator's home directory, both outside
the item's stated boundary ("paths outside the KB stay as they are"):
-
_linked_kbs'sexcept OSError as exc: absent.append(f"{linked.name} ({exc.strerror or exc})")— theor excfallback stringifies a bareOSError, whose default__str__includes.filenamewhen set. The path involved is a linked KB's resolved location, notmanifest.root— legitimately a different KB elsewhere on disk, not this one — so it falls outside "paths outside the KB stay as they are" by the same reasoning that already keepshf_cache_dir()untouched. Rare: only fires whenwhy_not_a_kb'sOSError.strerroris falsy, an edge case its own docstring already calls out as rare (an unreadable parent directory). -
budget.prices.PricesMissingError(reason=str(exc))— ships a package-relative path (prices.toml's location inside the installed wheel or an editable checkout), notmanifest.root-derived, so_de_homedcorrectly leaves it alone. In an editable/source install, that path is often literally under the developer's home directory too (e.g.~/Code/pinakes/src/pinakes/budget/prices.toml) — the same shape of leak ashf_cache_dir(), for the same reason (a real filesystem location worth showing, not KB-derived).
Worth keeping: if "no home directory in pnk doctor output, ever" becomes the actual goal
rather than "no home directory via the KB's own location", both of these are where to look next
— they were not fixed here because the item's own text draws the boundary at manifest.root, and
extending it is a separate decision.
measure_sync_cpu.py measured the launcher, not the work (20260805 17:37)¶
HIGH — the tool answered its one question with a number that was precisely, confidently wrong,
and every test passed. sample_percent ran ps -o %cpu= -p <pid> against the pid it launched.
The invocation the tool was written for — and prints in its own --help and changelog fragment —
is -- uv run pnk sync ..., which makes uv the measured process and pnk its child. uv waits
and burns nothing.
Measured on this repo before the fix, one identical one-core busy loop:
| launched as | reported |
|---|---|
| the busy loop directly | 1.0 cores |
the same loop behind uv run |
0.0 cores |
The failure mode is the expensive one: 0.0 cores for a sync saturating a core does not read as a
broken tool. It reads as the finding item 6 went looking for — "the loop is not CPU-bound, so
multiprocessing buys nothing" — and it would have been quoted into a design decision.
Why the tests could not catch it. All seven ran sys.executable -c <busy loop>: a direct child
that does the work itself. The suite covered the sampler, the units, exit-code propagation, empty
and non-positive arguments, and the trailing-interval bug — everything except the one process
shape the tool exists to be pointed at. Coverage of the code was complete; coverage of the
invocation was zero. This is the recurring class named in docs/RETROSPECTIVES.md — an
assertion satisfied by something other than the property it names — reached from a new direction:
not a weak assertion, but a fixture that was never the real subject.
Fixed by summing %cpu across the root pid and every descendant from a single ps -A snapshot
(one snapshot, so a child starting or exiting mid-walk cannot be double-counted or missed), plus a
test whose command is a launcher that burns nothing itself.
The new test's upper bound is load-bearing, and mutation proved it. With the tree walk
neutered, the launcher still reported 0.1 cores of its own interpreter startup — so
assert peak > 0 would have passed the mutant. Asserting > 0.5 fails it. A threshold above
"anything a waiting process can produce" and below one core is what makes the assertion name the
property; "non-zero" would not have.
Also corrected: %cpu is a decaying average over up to a minute (man ps), not the
instantaneous reading the docstring claimed. Right for the steady-state multi-minute loop this
measures, but it means peak is the peak of a smoothed series — a low peak is much weaker
evidence of an idle machine than a high peak is of a busy one, and the docstring now says so.
Generalisable: when a tool's purpose is to be run one particular way, one test must run it that way. A synthetic fixture chosen for speed silently replaced the subject here, and no amount of assertion strength on the wrong subject would have helped.
Fixture copies carried the developer's own .pinakes/ into the test workspace (20260805 17:49)¶
MEDIUM — three tests failed on main immediately after two clean merges, on a machine where
nothing was wrong with the code, and the failure impersonated the exact defect this repo watches
for. CLAUDE.md says a clean auto-merge is not a correct merge; both branches had been green
individually; main then failed. Every signal pointed at a bad merge. The cause was a
tests/demo-kb/.pinakes/index.db dated 1 Aug — a leftover from a manual pnk sync predating the
graph release's schema_version 3 bump — which five shutil.copytree calls copied into the test
workspace along with the documents.
The tests were coupled to whether the developer had ever run the tool by hand. CI clones fresh,
so .pinakes/ never exists there and the suite is permanently green; a dev box that has exercised
the fixture once fails until the directory is removed. .pinakes/ is gitignored, so nothing in the
diff, the merge or the branch could have shown it.
The idiom was already in the codebase and applied to four of nine call sites.
ignore=shutil.ignore_patterns(".pinakes") was used in test_eval.py, test_search_reproducibility.py
and twice in test_partner_kb.py. The other five simply omitted it. A guard applied at some call
sites is not a guard — it is a coin flip weighted by which test the copy happens to be in, and no
gate could notice the omission because the states it protects against are gitignored and absent in
CI.
Verified in both directions, planting a poisoned fixture rather than reasoning about it. With a
deliberately corrupt tests/*/.pinakes/index.db in place, all 105 tests across the four affected
files pass. Removing the guard from one call site fails exactly that site's test
(test_edges.py::test_the_stored_edge_set_agrees_with_the_probe_the_decision_was_taken_on,
StoreError), which is what makes the guard's presence the property under test rather than an
incidental line.
Generalisable: when a test copies a directory the tool also writes into, the copy must name what it excludes. And a loud environment-coupled failure is not a cheap one — this one cost real time precisely because it arrived wearing the costume of a merge defect, right after a merge.
An exact assertion between two different roundings — green locally, red on one CI leg (20260805 17:55)¶
MEDIUM — a coin-flip assertion that passed for the wrong reason, and whose failure was earned by
an unrelated correct change. test_reports_cores_the_way_macos_percent_converts_to_them read the
two numbers off the peak: line and asserted cores == pytest.approx(percent / 100.0) — at
approx's default relative tolerance, i.e. effectively exact. But report() renders percent at
0 dp and cores at 1 dp: they are two roundings of one value, not one value printed twice. Exact
agreement is a coincidence of the input, never a property of the code.
It held only while a single-process sample sat at exactly 100.0. The tree-sum fix
(measured the launcher, not the work)
made a one-core loop
read 101.4 — parent plus child — and "101"/100 != 1.0 turned CI red.
The tell is which legs failed. check (light pdf) failed; check (light) and
check (light pdf claude) passed on the same commit, same code, same test. Three legs disagreeing
about one assertion is not a flaky environment — it is an assertion whose truth depends on a
measured value nobody controls.
And it failed on merged main, not on the branch. The branch's own ./check.sh was green,
twice, because this machine's readings rounded agreeably. A local gate cannot rule out an assertion
that is only usually true.
Fixed with abs=0.06 and the arithmetic written down: 0.5 of display error in the percent is
0.005 of a core, plus 0.05 from the cores field's own rounding, so 0.055 is the largest honest
disagreement between the two fields.
The loosened bound still bites — verified, not assumed. Dropping the /100 from
CpuTrace.cores fails exactly this test (1.0 against 101). A tolerance that admits formatting
disagreement while still rejecting arithmetic disagreement is the assertion the test always meant
to make.
Generalisable: comparing two rendered numbers is comparing two roundings. Either compare the values before formatting, or state a tolerance derived from the display precisions — never an exact comparison "because they should be equal". This is the repo's recurring class once more: the assertion named "the conversion is right" was actually testing "the two roundings happen to agree".
Mutation testing found a guard that could not fire (20260805 19:07)¶
LOW as a defect, worth keeping as a method result. The numbered-heading predicate's
document-level check was written straight from its own spec, which says the numbers must form a
valid outline walk and that no number repeats. Both were implemented: a seen set alongside the
step-validity rule.
Mutating the clauses one at a time, eight of nine mutants were killed by the test named for that clause. The ninth — deleting the no-repeats check entirely — broke nothing.
The first instinct is "write the missing test". The right answer was that no such test can exist: every step the walk permits raises the number tuple lexicographically — a sibling raises its last component, a first child appends to it, an ancestor's next sibling raises a shallower one — so an accepted sequence is strictly increasing and a repeat is unreachable. The check was dead code wearing a guard's clothes.
It was removed rather than kept as defence in depth, and the reasoning put in the docstring. A guard that cannot fire still reads as one, and the next person to touch the step rule would weaken it believing this had their back. The spec keeps the no-repeats sentence — as a statement of intent it is correct, and the implementation note now says why it needs no code.
Generalisable: a surviving mutant asks a question before it asks for a test — is this reachable at all? Adding a test for unreachable code is how dead code acquires the appearance of coverage. This is the inverse of the failure this project keeps meeting: usually an assertion is satisfied by something other than the property it names; here a guard was satisfied by something other than itself.
Running it found what reading it could not (20260805 19:15)¶
MEDIUM, and the reason it is recorded beside the note above: the two findings came from opposite methods on the same increment. The dead guard was found by mutating code. This one was found only by building a KB and using the feature the way a user would.
[chunking] headings = "numbered" added to an already-synced KB, then a plain pnk sync:
| result | |
|---|---|
plain pnk sync |
1 unchanged · every heading_path still empty |
pnk sync --rebuild |
1 indexed · the three heading paths, and the first parent-child edge |
An incremental sync re-chunks a document only when the document changed. A manifest-only edit
changes no content hash, so the feature silently does nothing — and pnk doctor then reports
exactly the condition the user just tried to fix.
Every test passed throughout, and no test could have caught it. The unit tests call
chunk_document directly with the parameter set; the mechanism that drops it lives in sync.py's
change detection, one layer up. The defect is not in either component — it is in the seam, and a
seam is only visible from outside both.
It is also pre-existing: max_tokens and overlap have always behaved this way. Three releases
did not surface it because no [chunking] key had ever been worth flipping on a KB already indexed.
Adding the first one that is, is what made an old defect newly reachable — and "my change did not
cause this" is not the same as "my change did not make it matter".
Generalisable: for any change that adds a knob, turn the knob on a real KB before landing. Unit tests verify a component honours a parameter; only using it verifies the parameter arrives.
A warning that cleared itself without the fix being applied (20260805 20:20)¶
HIGH — the first draft turned a silent defect into a lying one, and every test passed. The
chunking-drift warning was correct. What was wrong sat 300 lines away: sync.py wrote the current
chunking identity into meta at the end of every sync, including the incremental one that had
just refused to re-chunk anything.
So the sequence was:
| step | what the user saw | what was true |
|---|---|---|
edit manifest, pnk sync |
1 unchanged + the new warning |
index still built the old way |
pnk sync again |
1 unchanged, no warning |
index still built the old way |
pnk doctor |
OK chunking coherence |
index still built the old way |
A warning that clears itself without the fix being applied is worse than no warning: it converts "the tool said nothing" into "the tool said it was fine". The index actively claimed a coherence it did not have.
Found by running it a second time, not by testing it. The unit tests asserted the warning
appears — it did. Nothing asserted it persists, because persistence only fails on the second
invocation, and a test that runs an operation once cannot see a defect that needs it twice. The
fix's own test is now ..._persists_until_the_rebuild_actually_happens, which syncs three times.
The correct rule turned out to be narrow: record the identity only when every chunk in the index was produced by this run — a rebuild, or a first build into an empty index. An incremental sync re-chunks only what changed, so after one the index is a mixture, and there is no single honest value to record. Leaving the old value is right: it keeps warning, which is exactly what a mixed index deserves.
Generalisable, and it is the second time today: a state-writing side effect belongs with the
work it describes, not with the command that happened to run. set_meta is called once per sync and
was treated as "the place identity goes" — but identity is a claim about the chunks, and only one
of those code paths actually produced them all.
The corpus rejected two of my fixes, and that is the result (20260805 21:00)¶
The measurement §5.3 demanded was run, on real RFCs, in doubling rounds: 66 → 131 → 259 → … The rule was state the predicate first, measure second, and treat a poor match as a finding rather than a licence to loosen a clause. It held, twice, in the direction that costs something.
Round 1 (66 documents) found a false positive. RFC 769 lists facsimile command codes as
56 - SET-UP, 57 - DATA, 58 - END. Consecutive integers, short labels, column 0, blank lines
around — every clause passed, and the predicate produced three headings that are not headings.
The first fix was wrong, and the corpus said so. "A heading's title must not begin with
punctuation" kills it. It also killed three genuine documents: 5.1. /get, 2.7.3. "iprev", and
RFC 2010's entire outline, which numbers real sections 1 - Rationale and Scope — the identical
shape as the false positive. Form cannot separate them. What separates them is where they
start: an outline begins at section 1, a list of opcodes begins at 56. That rule changes exactly
one verdict across the corpus, and it is the wrong one. It shipped as clause 9.
Round 2 (131 documents) found a second false positive, and rejected a second fix. RFC 778
numbers a procedure — 1. Connect to COMSAT-GAT host…, 2. Send the command… — starting at 1,
so clause 9 does not catch it. The obvious discriminator is that a heading stands alone: require a
blank line after the candidate. Measured, it removed the false positive and four genuine
documents with it, because real headings wrap:
7.4. The Network Information Center and
Requests for Comments Distribution Contact
Rejected, and RFC 778 is recorded as an accepted bound instead. Labelling the steps of a
numbered procedure as sections is defensible; 56 - SET-UP was not. Not every false positive is
worth a rule, and a rule that costs real structure to buy a marginal one is a bad trade even when
its net count looks fine.
What the corpus did buy: clause 10. A recurring convention numbers top-level sections 1.0,
2.0, mixing the two freely — RFC 2006 runs 6 then 7.0, RFC 2024 runs 1.1 then 2.0. Read
literally those are depth changes no walk can accept, and the document is rejected whole.
Normalising a trailing zero fixes it, and is safe precisely because a real subsection never carries
.0.
Clause 10's own test then caught a bug the walk could not see. The walk normalised 2.0 to 2
and accepted the document — while the heading stack still used the raw depth, nesting 2.0 under
1.0. The document passed and the hierarchy was wrong. Two places consume a number's depth and only
one had been taught the convention.
Generalisable, and the reason the doubling protocol matters: clause 9 was derived from 66 documents and looked complete; 131 documents produced a false positive it could not catch. A fix validated at one corpus size has been validated at one corpus size. Every round both re-checks the previous fixes and gets a vote on the next.
The sync-CPU number, and the instrument proving itself in the field (20260805 21:56)¶
The measurement item 3 demanded since 20260804 was finally run, and it reverses the item's own
framing. 55 modern-era RFCs — 16 557 chunks — rebuilt under fastembed:
| wall-clock | 1 497.7 s (~25 min), 1 451 samples |
| peak | 500% — 5.0 of 10 cores |
| mean | 480% — 4.8 of 10 cores |
The loop is serial and the backend under it is not. sync.py embeds one document at a time, so
the loop is single-threaded — but ONNX Runtime is already using half the machine beneath it. Item
3's own fork therefore resolves against the change it was written to consider:
The backend already saturates the machine → the loop is fine, and the win is a bigger batch (embedding several documents' chunks in one
embed()call), not processes.
It also lands exactly on the trap that item named: "do not stack a process pool on top of a
threaded backend". At 4.8 cores already consumed, two workers would take ~9.6 of 10 and
anything beyond oversubscribes. The intuitive fix — a pool sized os.cpu_count() - 1 — would have
been nine workers on a machine with room for two.
The instrument proved itself in the field, and it is the reason to trust the number. Sampled live from the same process tree:
| process | %cpu |
|---|---|
measure_sync_cpu.py |
0.7 |
uv run |
0.0 |
the actual pnk sync python |
491.9 |
The pre-fix tool watched the launched pid — uv run — and would have reported 0.0 cores for a
workload using five. That is not a number anyone would have questioned: it is the finding item 3
went looking for, and it would have licensed exactly the process pool this measurement rules out.
A tool whose failure mode is "confirms your hypothesis" is the most expensive kind.
Bounded, and the bound is stated rather than buried: fastembed only. sentence-transformers
needs the 2 GB [st] extra and is unmeasured, so nothing here licenses a claim about torch.
A second number fell out of the same run, unasked: 15 559 of 16 557 chunks carried a
heading_path — 94%. The corpus that opened this whole line of work indexed 106 806 chunks with
zero. The grammar works on real documents, and that is the first evidence of it outside a test.
Removing a blanket guard exposed what it had been incidentally protecting (20260805 22:11)¶
MEDIUM — and the finding came from an existing test, not from reading the diff. pnk init's
emptiness check was removed so a directory with content could be adopted. test_ci.py then failed:
it asserted that an existing .github/workflows/pinakes.yml is refused, and its evidence was the
string "not empty".
The obvious read is "update the assertion". The real finding is that the emptiness check had been
holding a second job nobody had written down. write_workflow does refuse to overwrite a
hand-edited workflow — but it runs after pinakes.toml has been written. With the emptiness check
gone, that refusal would leave a half-made KB: a manifest the user never asked for, and a re-run
that now fails with "already a KB", whose only way forward is deleting a file init created
itself.
Moved to _check_target, before any write, with the failure mode in the comment. The test now
asserts "already exists" — more precise than "not empty", and it names the only thing actually
in the way.
A second decision was refined in the same pass, and it is recorded rather than quietly taken.
The decision as written said "add a refusal naming any file init would write that already
exists". Implemented literally, that refuses on README.md and .gitignore — which a real
repository always has — so adoption would still have been impossible in exactly the case the item
exists for. The intent was "do not destroy the user's files"; the implementation honours it by
never overwriting and reporting, which is strictly safer than refusing and actually achieves the
goal. --ci is the one exception, because it is an explicit request rather than a side effect.
Generalisable: a coarse guard removed is not one behaviour removed. Before deleting one, ask what else has been quietly standing behind it — and take the failing test that follows as evidence about the system, not as a chore.
Slimming CLAUDE.md — a relocation's real cost is its pointers (20260806 00:33)¶
HIGH — the reference sweep has to run on the source file's name, not on the text that moved.
Extracting two sections out of CLAUDE.md left seventeen citations across the tree pointing at
content the file no longer carries. Not one of them quotes the moved wording; they name the file —
`CLAUDE.md` calls "the free path stays free" non-negotiable, "the invariant CLAUDE.md gains
says so explicitly", "for the reason CLAUDE.md states". A grep for the moved sentences finds
zero of them, which is exactly what "keep docs in sync — grep for what changed" instructs you to
run.
The two sweeps measured the difference:
| Sweep | Scope | Found |
|---|---|---|
| Neighbourhood audit, on the moved terms | docs/, tools/ |
4 |
Second pass, on the string CLAUDE.md |
whole tree | 13 more, all in src/ and tests/ |
src/ and tests/ were never opened by the first pass, because a docs-only change does not look
like it touches them. They held the majority: embed.py, sidecar.py, cli.py, ids.py,
extract/claude.py, extract/pageyield.py, budget/ledger.py, and five test docstrings, each
citing an invariant that had just moved to
docs/INVARIANTS.md. The
rule now: after relocating anything out of a file, grep -rn '<that filename>' over the whole
tree and re-judge every hit — a pointer names its target, so it is invisible to a
content-based search.
MEDIUM — docs/INVARIANTS.md is an index because the facts already had owners. Before
extracting, each of the nine invariants was checked against the docs that would hold it: eight
were already stated in DESIGN.md (§1 allowlist, §2.2 sidecar, §3 storage/schema, §5 ledger and
Decimal), MANIFEST.md (the id rows, the round-trip bounds table), VERIFICATION.md (§ The
sidecar round-trip) or CLI.md (--clear-cache=paid). A verbatim move would have created a second
copy of eight facts inside the file set whose stated rule is one fact, one home — and a second
copy drifts silently, because nothing compares them. So the page links each owner and writes out
only the five implementation rules nothing else states.
MEDIUM — the no-loss check has to be mechanical. Normalising every sentence of the old file and
matching it against the new homes flagged 29 candidates: 25 were false positives and 4 were real
losses — a measured number (980 RFCs), the "first time since it opened" qualifier, a plan's
self-description, and the why behind read the clock, never compose a timestamp, whose only
in-repo home had been the sentence being deleted. Re-reading the diff would not have separated those
four from the twenty-five; only a per-sentence check did.
Not fixed, out of this change's scope: tests/test_extract_claude.py and
tools/record_claude_fixtures.py both still require a local YYYYMMDD HH:MM for a recording's
--at, against the repo's UTC rule adopted 20260804 11:32. One for
plans/20260731_1202-open-corrections.md.
A plan's blocking premise was inferred, not measured — and it was wrong for two days (20260806 04:05)¶
HIGH — the reason a plan gives for a blocker is reused long after the blocker is gone.
plans/20260805_1721-metadata-as-retrieval-context.md said tests/demo-kb could not measure the
injection experiment because "no section spans multiple chunks, so there are no continuation chunks
to rescue — the mechanism has nothing to act on". Measured with the real chunker and the real
tokenizer over all 30 documents: 30 of 30 sections span more than one chunk, and 29 of the 30
continuation chunks do not contain their own heading text. The corpus carries the exact mechanism
the plan said it lacked.
The error was an inference from the wrong mechanism. The plan's fact was right — the documents are
~7 lines — but it reasoned that a short document fits in one chunk. chunk.py splits on paragraph
blocks first (Block is "one paragraph under one heading path") and only then applies token
limits, so a 7-line document with two paragraphs yields two chunks of 27 and 31 tokens under a
120-token budget. Nothing about the token budget was ever reached.
What makes this worth recording is that the plan's conclusion was right. Use the RFC corpus — correct. So the wrong premise cost nothing at the time and would have cost a great deal later: it is the sentence a future agent quotes when deciding whether some other experiment can run on the demo KB. A conclusion is checked when it is acted on; a reason is copied forward unexamined.
MEDIUM — the honest reason demo-kb cannot license this is arithmetic nobody had done. 66
answerable questions, 4 misses, and 56 of 62 hits already at rank 1. The whole improvable
pool on recall@k is 4, and the project's own sign_test(4, 0) returns p = 0.0625 — so a
perfect result fails the p < 0.05 bar the graph channel was held to. That is a power limit, not
a mechanism limit, and the two have different remedies: a mechanism limit is fixed by a different
corpus, a power limit by a different metric or more questions. Writing the wrong one down sends the
next person to the wrong fix.
MEDIUM — reasoning from a committed artifact nearly produced a confident wrong number. The miss
count above was first read out of tests/demo-kb/eval/outcomes.json, whose header predates G5 —
no graph_channel, no edge_kinds, no retrieval.adjacent_k, and graph_gate.read_leg reports its
channel as (absent). Two independent reasons the number could not be trusted from that file alone:
the artifact is only rewritten under --write-baseline, so CI never refreshes it; and compare()
tolerates a ±0.02 drift, which spans 4 to 6 misses — and sign_test(5, 0) = 0.0312 passes.
The claim "a perfect result cannot license" was therefore one question wide. Re-running the eval
settled it: aggregates identical, and all 74 rows match the committed file exactly. The rule is
not "distrust artifacts" but an artifact whose header does not match the current binary is
evidence about the past — the identity check graph_gate.check_identity performs on legs is the
same check a reader owes any committed number.
MEDIUM — four silent failures sat in front of an experiment costed at "~2 h rebuild + eval". The
RFC corpus stamps no max_tokens, so the default 510 applies against a measured window of
512 with 2 special tokens: zero headroom, and prepending anything pushes every full chunk
past the window. Embedding an over-length string raises no warning and no error (measured, empty
warnings list), and assert_chunkable cannot catch it because it validates max_tokens, never
max_tokens + prefix. The direction is what makes it dangerous: truncation removes text from
exactly the long chunks the hypothesis is about, biasing the result toward no movement — a false
negative that reads as a clean result. Separately, the lexical channel cannot be injected at all
without a new chunks column, rewritten FTS5 triggers and a schema_version bump, because
chunks_fts is an external-content table filled by triggers copying new.text.
The estimate was not wrong about the run; it costed the run alone. Step 2 is three increments and a measurement, and none of the three was visible from the plan as written.
LOW — a plan that forbids retrying below "the threshold" has to name it. The document's anti-circularity clause read "a result short of the threshold is reported rather than retried" while nothing in it ever said what the threshold was. An unfalsifiable gate is not a gate; it was fixed by decision rather than discovered, but it survived a full adversarial review of the plan because the sentence sounds like a commitment.
2a — Real titles and a measured chunking reserve for the RFC corpus (20260806 06:12)¶
The plan's worked reserve was wrong by more than twofold, and only measuring found it. The
injection experiment's plan offered 480 as the corpus max_tokens, from "a measured max prefix of
30" — RFC 9110's largest title > heading_path with section numbers stripped. Measured properly,
over every heading path of RFCs 8600-8799 (195 documents, 5 of the 200 numbers unpublished),
tokenised with the corpus's own BAAI/bge-small-en-v1.5: the largest prefix is 68 tokens, the
per-document largest has median 31, p95 51, p99 61. The median document already exceeds 30.
RFC 9110 was an unrepresentative sample for one reason nobody spotted: its title is two tokens
long, and titles run to 32.
Reserving 30 would have truncated roughly half the corpus's longest chunks — silently, since an over-length embedding input raises no warning and no error. That truncation removes text from exactly the continuation chunks the hypothesis is about, so it biases toward no movement: a false negative that reads as a clean result. The reserve shipped is 96, deliberately 41% above the measured maximum, because 200 numbers is under a third of the modern band.
The plan said "do not re-derive these" about a table whose own line numbers had already drifted. Same failure class, one level up: a number recorded as measured invites reuse, and reuse is exactly what makes a sampling flaw permanent. The four "read this before writing a line" findings in §2 were all correct and all load-bearing; the one worked number beside them was not.
A defect I nearly introduced, caught in my own adversarial pass. Preserving the KB id across a
re-run — needed once the output directory holds permanent document ULIDs — makes a re-run look
identity-preserving while write_kb still rewrote pinakes.toml. That would silently discard the
[retrieval.confidence] thresholds 2c will fit onto this corpus, with every command reporting
success: the same KB by its id, no longer the same KB by its calibration. The fix removed the
reason rather than the symptom — an existing manifest is not rewritten at all, so the id is stable
by construction and the existing_kb_id reader that motivated the concern was deleted. Its cost is
that a re-run does not adopt a changed [chunking], so the run now names both values.
What nothing else in the repository exercises: a title with YAML punctuation. RFC 8713 is
"IAB, IESG, IETF Trust, and IETF LLC Selection, Confirmation, and Recall Process: Operation of…".
Every committed corpus is hand-titled in plain words, so the first colon in a title: arrives from
the RFC Editor — into the one file holding a document's permanent ULID. ruamel quotes it and it
round-trips; that is now asserted through sidecar.read rather than assumed.
Verified by execution, which the plan had none of. Two RFCs built and synced with the real
fastembed backend: both documents indexed under their published titles, 801 chunks, largest
token_count exactly 414 — so the stamped cap binds on real text, as finding 1 said the default
510 does against a 512-token window.
A bound left in place, not fixed. corpus.json describes the run, not the directory: a
re-run over a smaller --rfcs set records fewer RFCs than docs/ still holds. Pre-existing, and
now slightly more visible because titles.kept_from_earlier_run hints at accumulation. Worth
fixing before the run at 2f, where the corpus's composition has to be attributable.
2b — Refusing a prefix that would not fit (20260806 08:24)¶
A refusal with no caller is a refusal nobody has watched fire, so this one was written to be
runnable on day one. The manifest option that turns injection on belongs to 2d, so nothing on the
indexing path calls assert_prefix_fits yet. The temptation was to ship the check alone and let 2d
supply the thing that exercises it. What shipped instead is the whole prefix construction —
metadata_prefix, embedding_text, and the unnumbered_heading_path they read — so the refusal
measures exactly the string that will later be embedded, and every part of it is reachable from a
test today. 2d then adds one option and one call, not a mechanism.
The additive estimate was measured rather than argued. The check counts each distinct prefix
once, with its separator attached, and adds chunk.token_count rather than re-tokenising every
injected string — a document has orders of magnitude fewer heading paths than chunks. That is only
safe if the sum never under-counts the concatenation. The reasoning (a tokenizer that splits on
whitespace before merging cannot produce more tokens from prefix + sep + text than from its
parts) is sound but is the kind of reasoning that is wrong once per project, so it was run: against
BAAI/bge-small-en-v1.5, over 43 503 chunk/prefix pairs from 195 RFCs, the estimate was
exactly equal to the concatenation's real token count every time — not merely bounding.
The same run reproduced 2a's corpus figures from an independent code path (largest prefix 68,
per-document largest median 31 / p95 51 / p99 61, longest title 32) and confirmed the refusal fires
for 195 of 195 documents at the default max_tokens = 510 and for none at the corpus's 414.
The reserve is checked, not the worst chunk in hand, and that was a decision. Per-chunk pairing
is more permissive and more exact: it refuses only what would actually truncate today. It was
rejected because the two legs of an A/B comparison must chunk under the same max_tokens or they
are different corpora — so what has to be safe is the setting, not this morning's text. A
document that passes because none of its chunks happens to reach the cap would start truncating on
the next edit, mid-experiment, silently. Refusing the setting is stable across documents and across
edits to them.
A field added to a frozen dataclass is a field somewhere else forgets to copy. _with_pages
rebuilt every PDF chunk field by field to attach page numbers, so unnumbered_heading_path would
have arrived as None for PDFs only — green suite, silent gap. It now uses dataclasses.replace,
which cannot omit a field, and a test pins the property. The same reasoning kept the field out of
as_row: the stored form is the citation form, and a second column is a second thing to keep in
step with it.
Markdown keeps whatever number its author typed, and that is the same rule rather than an
exception to it. ## 1. Introduction yields 1. Introduction in both paths, because nothing
parsed a number there — # is syntax and is already gone, and the text after it is the author's.
Only the grammar that parsed a number is entitled to remove one. A regex over the joined string
would have been shorter, would have drifted from the grammar's own rule, and would have eaten the
404 from # 404 Not Found.
Two findings from the increment's own adversarial pass, both about a value with more than one
home. The first: the prefix's join was introduced as a new constant, which would have made four
literal " > "s in this module plus graph/edges.HEADING_SEPARATOR reading them back — for a
format that is persisted in chunks.heading_path, where a disagreement empties three edge
kinds and reports nothing. It is now one HEADING_JOIN used at every site that builds a heading
path, with the consuming copy named in its docstring. The second: title is the user's field in a
hand-edited sidecar, so it can be "" or " ", and the first draft treated whitespace as
content — injecting a separator with nothing in front of it into every chunk of that document.
Neither would have failed a test or a run; both are the shape this project keeps meeting, a value
that is wrong in a way nothing reports.
2c — A golden set authored by agents that did not know what it was for (20260806 11:42)¶
The anti-circularity rule was implemented rather than promised. The plan requires the questions frozen before any injection code exists, "so that no number can influence them" — and the deeper risk it names is fitting the question set to the mechanism, which "is undetectable afterwards". An author who knows the hypothesis cannot prove they ignored it, and no reviewer can check it from outside. So the set was authored by six agents over disjoint document slices, each told only that it was writing an evaluation set for a retrieval system and forbidden from reading this repository, where the plan would have told it. Blind authorship is the only version of that guarantee that survives review.
The exit criterion failed the first time, and how it failed was the useful part. 70 questions
produced an improvable pool of 9 against a criterion of 10. The shape mattered more than the
number: 51 of 60 answerable questions were already at rank 1, lexical and simple-lookup both
scored 1.00, and 8 of the 9 pool members were paraphrase. On a corpus of distinctive
technical vocabulary — protocol acronyms, registered code points, field names — BM25 with a
reranker essentially solves the classes that share words with their document.
The obvious fix was the wrong one, and it would have passed. Authoring more paraphrase
questions reaches a pool of 10 quickly. It also enriches the set with exactly the class a change to
the embedded text is most likely to move, which is fitting the instrument to the hypothesis one
step removed: the criterion would pass and the result would mean nothing, with nothing in the
artifact to show it. The two new slices carried the same proportional mix as the first four,
over 135 documents no question had touched. The pool went to 15, and it changed shape — 11
paraphrase, 2 lexical, 2 simple-lookup, where before it was one class. A pool spread across classes
is what makes compare()'s per-class guard able to catch a change buying one class out of another.
The expensive error in a question set is self-concealing, so the set carries its own evidence.
A question pointing at the wrong document looks exactly like a retrieval miss. It would have
inflated the pool and made the power criterion pass for the wrong reason — the measurement then
resting on questions nobody could answer either. Every answerable question therefore records the
sentence from its document that answers it, verbatim, and tools/verify_rfc_golden_set.py refuses
the set if a sentence is not there. All 96 verified. Whitespace is normalised because RFC bodies
are hard-wrapped at ~72 columns, which also means the recorded evidence is often the fragment on
one line rather than a whole sentence.
A slice can only check its own slice. Each author confirmed its no-answer topics absent from
its own 50 documents. Checked across all 195, three had mentions elsewhere — "camel case" in
rfc8618 against a question about the CAMEL protocol, one mention of blockchain databases in
rfc8673, Bluetooth as a transport example in rfc8628 and rfc8793 — and later, SCADA as a category
label and NFC as both a URI transport and Unicode Normalization Form C. None answers its question,
and all were kept deliberately: a calibration set wants plausible near-misses, and a lexical
collision that answers nothing is exactly that. Two topics were duplicated across slices and
dropped.
filter and multi-hop are absent by decision. filter needs metadata worth filtering on and
these sidecars carry a title and nothing else. multi-hop is the graph channel's class and
graph_channel is off here, so such a question would score as an ordinary lookup while being
reported under a name claiming otherwise.
The corpus is not committed, so everything that reads it had to be. The questions, the fitted
confidence thresholds and the before leg all live in the repository; the 195 documents do not.
That is the same lesson build_rfc_corpus.py was written for — a 300-RFC corpus once produced this
project's most useful finding and died with the machine that held it. The thresholds are stamped
into the builder's manifest template rather than pasted into a generated pinakes.toml, which also
makes both legs of the comparison fitted identically by construction: refitting after a change
would measure the refit.
A defect found by asking what would protect the artifact this increment produces. Capturing a
before leg raises the question of what stops it being compared against an after produced
differently — and graph_gate.check_identity turned out to guard only the graph channel's own
settings. Reading eval.header for what it does record showed the gap was one level lower: the
function's docstring promises "every setting that can move a row", and [chunking] was not in it.
Every other outcome-deciding setting was. So the leg committed here could not have been shown to
have been chunked at 414 rather than the default 510 — the precise confusion 2a's reserve and 2b's
refusal exist to prevent, surviving into the artifact that records their result. Three fields, no
schema bump, because no row changed.
2d — The screen said no, and the controls are what make that worth believing (20260807 09:07)¶
The result: 6 improved, 6 regressed, 84 unchanged, over 96 answerable questions. The
pre-registered criterion was strictly more improvements than regressions, so the screen is a
no-go and the schema_version bump at 2e is not taken. Per the pre-registration these numbers
are the whole report — they are not evidence for or against the hypothesis in either direction, and
they appear here and in the increment's commit message and nowhere else. The screen's own artifacts
were deliberately not committed.
What was measured, stated exactly, because a null is only as good as its controls. Injecting
title > heading path into the text that is embedded — the vector channel alone — moved
nothing net on this corpus at rerank = "none". The both-channel form was not tested: the
lexical channel needs a new chunks column and a schema bump, which is the cost this screen
existed to decide. The dilution objection that disqualified vector-only as a gate applies in full
to this null: RRF fuses an injected vector channel against an unchanged BM25, so a real effect is
attenuated before it reaches a rank.
A null result is a claim about the world only if the instrument was pointed at it, so four controls were run before the comparison — three of which the plan did not ask for.
| Control | Result |
|---|---|
The uninjected index still reproduces 2c's baseline (rerank = "local") |
110 of 110 rows identical, twice — once on main's binary, once on this branch with the option off |
| Both legs are the same corpus | 195 documents, 43 353 chunks, and one sha256 over every chunk text, equal |
| The injection actually reached the vectors | mean cosine 0.8398 between the before and after vectors of 2 000 sampled chunks; zero unchanged |
| The prefix was the intended string | 195 of 195 published titles, zero filename stems — finding 5's confound absent — and 93.2% of chunks carrying a heading path |
The third is the one that decides how to read the null. Chunk texts are byte-identical between the legs by construction, so if the injection had silently not happened, every artifact in the experiment would look exactly as it does now — same corpus, same questions, a clean flat result — and the conclusion would be drawn from a no-op. Measuring the vectors is the only thing separating "no effect" from "no injection", and it cost one script.
The option-off path being a verified no-op is not a formality either. The same binary that
carries the injection reproduced the frozen baseline row for row, which is what licenses comparing
this branch's after leg against a before leg captured on it.
The shape of the movement is more interesting than the count, and it is not being reported as a
result. Of the 6 improvements, 5 were paraphrase — the class the hypothesis targets, and the
only class with power on this corpus. Of the 6 regressions, 2 were simple-lookup questions that
had been at rank 1. That is what a dilution cost looks like: a prefix adds tokens to a vector whose
question needed none of them. Twelve of 96 rows moved, so the mechanism is doing something; it is
simply not doing more good than harm through one channel. This is an observation about a
measurement that was pre-registered not to be interpreted, and it must not become the premise of a
retry — the anti-circularity rule says a result short of the threshold is reported rather than
retried with a different injection format.
Two things surfaced by building it that the plan had not anticipated.
- The refusal is a per-document failure, not an aborted run.
assert_prefix_fitsraises aChunkingErrorfrom inside_index_document, and everyPinakesErrorthere is already caught by sync's per-document handler: the transaction rolls back, the document is named in the report and recorded in the index forpnk doctor. That is the right shape — one pathological heading path should not cost a 195-document corpus its other 194 — and it still removes the silent truncation, because a refused document is not indexed at all. But "refuses the corpus" was the plan's phrasing, and the test written for it originally asserted a raise that never comes. - With injection on, every document is prefixed, because
skeleton()falls back to the filename stem. A document can reach the embedder with noheading_path; it cannot reach it with no title. So on an uncurated corpus the injected string is a filename — finding 5's condition, now located at the sync boundary rather than in the abstract. It is why the RFC corpus mints published titles before its first sync, and it is the strongest argument for the option defaultingoff.
A test that passed vacuously, caught during development and worth naming. The assertion that
the injected prefix uses the sidecar's title was written over a sidecar-only edit — which is a
RefreshMetadata action, so it updates the row and re-embeds nothing. all(...) and
not any(...) over an empty list are both true, and the test was green while proving nothing. The
fix was --rebuild plus an explicit assert backend.embedded precondition. Any assertion of the
form "everything embedded looks like X" needs a companion assertion that something was embedded.
tools/two_leg_gate.py exists because the instrument had the same gap one level up. 5993521
made an eval artifact record the chunking it was produced under; nothing compared it —
graph_gate.check_identity takes three legs shaped to the graph channel and inspects k,
embedding, rerank, ranking and retrieval, but not chunking. Two legs chunked at different
max_tokens therefore compared clean, which on one RFC is 63 of 1 858 chunk texts differing: a
rechunk reported as the effect under test. The new tool refuses on any header difference outside
one named key, and it excepts that key by path, not by block — excepting the whole chunking
table would hide exactly the rechunk it is there to catch.
2d review — the grep that was necessary and not sufficient (20260807 09:54)¶
HIGH — a rebuild could leave a half-injected index, and the check that should have caught it was
looking for the wrong thing. Before landing the injection, this increment verified that
sync.py has exactly one .embed( call on the indexing path — grep -rn '\.embed(' src/
returns that one, plus two query-side calls. The conclusion drawn was "every vector on the indexing
path goes through the switch". It does not follow, and the counter-example was already in the file:
_copy_forward_protected_document writes rows into embeddings with an INSERT … SELECT from the
index being replaced. It produces vectors without embedding anything, so no grep for .embed(
could ever have found it.
The consequence was the failure class this project exists to prevent. A KB with a paid-extracted
PDF, [chunking] metadata flipped to "prefix", and pnk sync --rebuild: the protected document
keeps its uninjected vectors, set_meta stamps chunking_metadata = "prefix" over the whole
index, and the next pnk sync and pnk doctor both report no drift. Every command succeeds,
and half the index is injected.
The fix separates the two costs that had been treated as one. The docstring said the document is "never re-extracted, never re-embedded", as though those were the same protection. Extraction is what spends money; embedding is local and free, and the chunk texts are carried forward anyway. So the chunks are still copied and the extraction still never re-run, and the vectors are recomputed under this run's settings — which fixes both directions, since turning injection off again had the mirror-image defect.
One guard is deliberately louder than it needs to be today. unnumbered_heading_path is not
persisted (chunk.Chunk says why: the stored form is the citation form, and a second column is a
second thing to keep in step). A carried-forward chunk therefore cannot say what its path looks
like with the section numbers removed, so injecting the stored form would quietly prepend the
citation form this experiment measured at 44% numbers and rejected. Unreachable now — only PDFs are
ever protected and the PDF path records no heading path — but step 5 of this plan is PDF layout
heuristics, which is exactly what would make it reachable. It refuses with a named remedy instead.
What the review round found beyond that, all of it in code this increment wrote:
--sign-test printed FAIL at 0.05 and exited 0 |
the flag names the 2f gate, which licenses the irreversible schema bump; a driver branching on $? would have taken it |
A miss was written to the artifact as Infinity |
invalid JSON: JSON.parse rejects it, jq silently coerces it to 1.8e308 — the one outcome the rank ordering exists to make visible became a very good rank |
| A truncated artifact exited 1, same as a genuine no-go | read_outcomes only refuses a file that parses; a JSONDecodeError is a ValueError, which the first version of the handler did not catch |
| Nothing recorded which leg was which | transposing --before/--after inverts the verdict, and the identity check cannot catch it — it is never told which value is the baseline, only that the two must differ |
pnk doctor's half of the drift promise was untested |
a mutant reading a constant there passed the entire suite |
| Two more vacuous assertions | backend.embedded == [rows] is [] == [] when a run indexes nothing; all(…) over an empty list again |
Three of those six are the same defect in three costumes, and it is worth naming once. An assertion of the form "everything we produced looks like X" is silently satisfied by producing nothing. It has now been found three times in this increment alone — once during development, twice in review — so the rule is: any assertion over a collection the code under test produced needs a companion assertion that the collection is non-empty, and preferably one that names the expected count.
The review that found this was itself a measurement, and it half failed. Five adversarial
lenses were run as independent agents; a usage limit killed 15 of the 17 agents mid-flight,
including every verifier. The workflow returned {"confirmed": [], "refuted": []} — which reads
exactly like a clean review and was nothing of the sort. The findings were recovered from the
agents' own transcripts and verified by hand. An empty result from a harness that partially
failed is not evidence of absence, and a report that does not distinguish the two is worse than
no report: confirmed: [] was one careless sentence away from becoming "the review found nothing".
2d review, round two — the argument for the key's placement was false when written (20260807 10:34)¶
HIGH — the option was put in [chunking] rather than [retrieval] because [chunking] is
recorded in the index and therefore cannot flip silently. That reasoning was correct about the
mechanism and wrong about every KB in existence. chunking_drift treats a key absent from meta
as unknown, never drifted — the rule that stops an upgrade demanding a rebuild of every index —
and chunking_metadata is absent from every index built before this release. Only a --rebuild
ever stamps the chunking identity, so the absence is self-perpetuating. Measured on a KB built by
this branch with the key then deleted, which is exactly the shape a 0.15.1 index has:
drift () embedded 0 backend calls 0
pnk doctor: OK chunking coherence: index matches the configured chunking
An affirmative OK over uninjected vectors, forever. For a pre-existing index the two manifest sections behaved identically, which is the thing the placement argument was written to rule out.
The fix turns on a distinction the original rule did not need. max_tokens and overlap have
been settable since v0.1, so an index that fails to record them could genuinely have been built
under any value: absence there is ignorance. chunking_metadata is different — no release that
could have written any existing index was able to inject anything — so absence proves off.
store.ABSENT_MEANS says so, and because it resolves to the default, it fires only for a user who
explicitly opted in: the compatibility guarantee it looks like it threatens is untouched. Both
directions have tests, and the second one matters as much as the first — an unclearable warning on
every upgraded KB is the failure mode the heading-coverage check already had to answer for.
MEDIUM — the fix for round one's finding shipped with the same class of hole inside it. Round
one found that --rebuild carried a protected document's vectors forward, and the fix re-embedded
them. But the re-embed called embedding_text without calling assert_prefix_fits, so the one path
that re-embeds without re-chunking became the only path with no truncation guard — and it is the
path that needs one most, since its chunks were sized by whatever max_tokens built the previous
index and are never re-chunked. A fix for a silent-truncation defect that reintroduced silent
truncation one function away.
MEDIUM — and it also introduced a way to publish a half-written document. DETACH requires the
transaction closed, so that function commits in a finally; with the writes inside that block, a
document whose embedding failed was committed active with chunks and zero vectors, which
_apply's rollback() could no longer undo and --rebuild's unconditional index swap then
published. No later sync repairs it: the file's content hash is unchanged, so every future run says
Skip. Now the old rows are read under the attach and every write happens after it, in one
transaction the caller can still roll back.
MEDIUM — the eval artifact labels a leg from the manifest, never from the index. Every
[chunking] value in a header is read from pinakes.toml at eval time. Because flipping metadata
changes no chunk's text, hash or span, an eval run against an index that was never rebuilt produces
a byte-for-byte plausible artifact stamped metadata: "prefix" over uninjected vectors — and
tools/two_leg_gate.py would accept it as the injected leg, since it compares headers to headers.
The instrument that licenses an irreversible schema bump could be handed a leg that never existed.
eval.run now compares the index's recorded chunking against the manifest and refuses before
scoring a single question.
The four rules this round leaves behind.
- A grep for the operation is not a proof about the outcome. Round one's defect survived a
check for every
.embed(call site because the offending path produces vectors withINSERT … SELECT. Round two's survived a reading ofchunking_driftbecause the defect is in what the function does with a key that is not there. Both times the search was over the wrong set — the question is never "where is this called" but "what else can reach this state". - A fix is a change, and gets the same review as one. Two of this round's findings are in code written to fix round one, hours earlier and with the defect fresh in mind.
- State an argument in terms of the population it covers. "
[chunking]is recorded, so the flip is reported" was true of indexes this release builds and false of every index that existed. The sentence never said which it meant. - A partially-failed harness must not report like a completed one. Round one lost 15 of 17
agents to a usage limit and returned
{"confirmed": [], "refuted": []}— indistinguishable from a clean review. The findings were recovered from the agents' transcripts by hand; one was the HIGH above. Round two ran complete: 14 findings, 14 judged, 9 confirmed, 5 refuted.
T1 — The version archive, and what mutation testing found in it (20260807 19:36)¶
HIGH — the plan's own baseline undercounted the sites it had just corrected, and the second
count was as wrong as the first. The template-release plan was re-verified against main at
71911e2 on 20260807, and that re-run specifically corrected the notes@1.0 site list: it had
said "both, in test_init.py", and the correction raised it to "six sites in five files", with a
box explaining that the original defect was a grep scoped to one file. Running the corrected
command at that same commit returns nine sites in eight files. The three it still missed were
tests/test_sync_links.py:66 and the two committed KB manifests, tests/demo-kb/pinakes.toml and
tests/partner-kb/pinakes.toml. The lesson is not "grep wider" — the plan already said that. It is
that a count in a document is a measurement, and re-running the command is the only thing that
distinguishes a corrected count from a confidently wrong one. The correction was written and
believed; nobody re-ran it.
HIGH — a formatter can edit a file the project has promised is frozen. The archive's whole
value is byte-identity: _versions.toml records a SHA-256, and pnk upgrade will diff against
those exact bytes. check.sh runs ruff format --check . over the whole repository, and ruff
reformats Python inside Markdown fences. A template README.md that ever gains a python fence
would be rewritten in its archived copies too — the project's own formatter editing a version
that already shipped — and it would surface as a ledger mismatch one leg away from its cause.
Latent today (no template README has a fence) and closed with a [tool.ruff] extend-exclude.
Generalisable: whenever a repository declares some bytes immutable, list every tool with write
access to them. The gates were audited; the formatter was not, because it is not a gate.
MEDIUM — mutation testing found a branch no input could distinguish, which is the same defect
class as a gate that cannot fire. git_history_reason probed --is-inside-work-tree and then
--is-shallow-repository. Neutering the first changed no test result: outside a checkout the
second exits 128 anyway, so the first was dead weight wearing the clothes of defensive care. It
was removed. This increment exists because pnk doctor carried a check that could never fire for
eleven releases; shipping a redundant branch inside its fix would have been the same mistake at
one-tenth the scale. A branch that no test can tell apart from its absence is a branch nobody
knows works.
MEDIUM — the first mutation run was invalid, and its invalidity was informative. Three mutants
changed content_hash and turned the whole suite red rather than one test, because the committed
ledger pins the hash function: change it and leg (iii) fires everywhere. That coupling is a feature
— the hash definition cannot drift silently — but it destroys the per-assertion signal, so the
harness was corrected to regenerate the ledger under each hash mutant, which is the tree such an
implementation would really have shipped with. Separately, two tests both tampered with
README.md, so a mutant exempting the README failed all three. A mutant that travels to another
test means the two tests share a vector, not that either is wrong — but the sharing is worth
removing, because it makes every future mutation result harder to read.
MEDIUM — leg ordering decided which remedy the user is given, and the wrong order gives the
opposite one. Editing a frozen _versions/<live>/ file also makes the live files differ from
it. With the live-vs-archive comparison first, the gate reported "the live files differ from
archived 1.1" and advised bumping the version — when what happened was that a published version
was edited and needs restoring. The ledger check now runs first, so each fault reports its own leg.
Found by running the failure paths, not by reading the code: both orderings look correct on the
page and only one is.
LOW — "exactly one commit" was unsatisfiable in the increment that introduces it. Leg (vii) as
specified fails on its own landing commit, because ./check.sh runs before the archive is
committed and git log returns zero. It is at most one: zero means not yet committed, one means
added and never touched, two or more is the violation.
HIGH — the gate reproduced, inside itself, the exact defect it was built to catch. Given
--templates as a relative path, leg (vii) built its git log pathspec against the process
working directory while git resolved it against the templates directory. It matched nothing, git
log returned empty, zero commits read as "not committed yet", and the gate printed
history leg (vii) ran … none edited and all legs green over a tree carrying the coordinated
three-file edit. That is the strong mode claimed while nothing was checked — the thing this
increment exists to stop pnk doctor doing. Every leg-(vii) test passed --repo explicitly, so
nothing covered it. A flag that changes where a tool looks needs a test that runs it from
somewhere else; resolving paths once at the argument boundary is the fix, and the general rule is
that a relative path and a cwd argument must never be chosen independently.
HIGH — leg (vii)'s first design blocked the project's own procedure, and no single mechanism
fixes it. Counting every commit that touched an archived directory fails a branch that adds one
and then corrects it during review — which is exactly what docs/BUILDING.md requires (green
./check.sh before review, review fixes in their own commit). The failure text said the archive
"still says what the version said when it shipped" about a version that had never shipped, and the
only escape — amend or rebase — is the operation that also defeats the leg. Two candidate fixes each
looked sufficient and each was blind: counting only commits already on origin/main stops catching
the coordinated edit before it merges; comparing content against origin/main stops catching an
edit that has already merged. The leg needs both halves, and the way to see that was a table of
three scenarios against two candidate rules, not a closer reading of either candidate.
MEDIUM — a test asserting on a configuration was satisfied by the prose describing it. The test
pinning fetch-depth: 0 onto the gate's CI job grepped the workflow file for the string. The job's
own comment explains why the setting is there and contains it, so deleting the setting left the
test green. Caught only by mutating it. Grep a config file and you assert about a document; parse
it and you assert about the configuration — the test now loads the YAML and reads
jobs.template-drift.steps[checkout].with.fetch-depth.
MEDIUM — nothing pinned that the gate was wired in at all. Deleting the check.sh line and the
entire CI job left all forty-odd tests in tests/test_template_drift.py green, because every one of
them drives the tool directly. A test suite for a gate proves the gate works; it says nothing
about whether anything runs it, and those are different claims. Two assertions now cover the
second one.
MEDIUM — the hash covered bytes that do not ship, and could bake them into the ledger. It walked
the working tree, so a gitignored .DS_Store in the template directory turned ./check.sh red on a
clean checkout with the remedy "bump the version and archive the new files". The worse direction:
present while --print-hash generated a ledger row, it was folded into the committed sha — leaving
the author green and failing only on a clean CI checkout, pointing at an archive nobody had touched.
The fix is ignored, not untracked, and the distinction is load-bearing: measured against a real
hatchling build, a gitignored file does not reach the wheel while an untracked-but-un-ignored
.orig does. Hashing git's tracked set would have hashed away a stray file that really
publishes, and given a brand-new archive the digest of the empty string.
Process note — two independent skeptics disagreed, and the disagreement was the useful output. One confirmed the stray-file finding; another refuted it, having implemented and measured a tracked-set fix and shown it strictly worse. Both were right about what they tested, and neither had tested the rule that was actually adopted. A refutation kills a proposed fix, not necessarily the finding — the two have to be judged separately, and the second reviewer's evidence is what made the third option obviously correct.
T2 — Reporting template drift as a line count (20260807 22:28)¶
HIGH — A test that never calls the function under test cannot kill a mutant inside it.
test_the_kb_identity_block_never_produces_a_hunk was written for the mutant the plan called the
one that matters: rendering the old side with the KB's recorded reference and the new side with the
installed one, which is what a reader of init.py:75 would naturally write, and which puts a
[kb] hunk in every report on every KB. The test asserted exactly that property — by rendering
both versions itself. So the mutant went into doctor._template, the test rendered its own two
sides correctly, and it passed. The property was right, the altitude was wrong. It now also asserts
the count doctor reports, against a synthetic pair that differs on one line: correct is two, a
leaking identity block is four.
HIGH — A line-count assertion is blind to an edit that substitutes rather than adds. The
invariance test edits the user's manifest and asserts the reported count does not move, which is
what catches a report built from pinakes.toml instead of from two archived templates. With that
defect injected, replacing fake-model with fastembed-model and final_k = 5 with
final_k = 4 left the count identical — one line replaced by another is still one line on each
side of a diff. The test was invariant under the implementation it existed to reject. Appending a
comment line fixes it, because no substitution can absorb an added line.
Both were found by running the mutation pass, not by reading the tests. Two of five mutants survived a suite that was green, and neither survivor was a bug in the increment.
MEDIUM — A comparison that reads one of four files must not report 0. A template version
denotes four consumed files; this check diffs pinakes.toml.j2. A bump touching only
eval/questions.yaml renders two identical manifests, and the first implementation said
0 lines differ — true of the manifest, read as nothing changed. It is not a hypothetical: of
the ten commits between the notes template's first version and its second, five touched the
golden set and none touched the manifest. It now says same manifest and names what a version
covers beyond it.
MEDIUM — The most-read string promised something no release can deliver. The cannot compare
remedy ended "from the next template version onward the comparison is automatic". Under D-2b
notes@1.0 is deliberately unarchived, so a KB recording it stays uncomparable however many
versions ship after — the sentence was false for precisely the readers who see it most, which is
every KB in existence. What a later version changes is the next KB, and the remedy now says that
and says the missing content is gone rather than pending.
A second copy of "the variables this build supplies" was one commit from existing.
tools/template_drift_gate.py leg (vi) asserts every archived version renders, under a context
written out in the gate. The product now renders both sides of a comparison under
template.render_context. Two literals, and the failure mode is the gate staying green while
pnk doctor raises on the KB in front of it — the gate would have been asserting that the archive
renders under a context nothing uses. The gate now builds its keys from template.CONTEXT_KEYS,
and test_render_context_supplies_exactly_the_declared_union pins the remaining seam.
T3 — pnk upgrade, print only (20260808 00:02)¶
HIGH — a same-length mutant is invisible to Python's bytecode cache, so a mutation run can report
the exact false negative it exists to prevent. Two of T3's eight mutants — == 1 → >= 1 on each
branch of the placement predicate — came back SURVIVED. Applying one of them by hand and running
the same test killed it immediately. The harness was mutating src/, and CPython invalidates a
.pyc on the source's (mtime truncated to the second, size): == 1 → >= 1 changes neither, so
a mutation written and reverted inside one second was executed from stale bytecode. Every mutant that
did die changed the file's length.
Two things follow, and neither is about this increment.
- A mutation harness must invalidate bytecode explicitly — delete the module's
__pycache__entry after writing and after restoring. Without it, "mutation-verified" is a claim the harness cannot support for precisely the smallest, most surgical mutants, which are the ones worth running. - The failure was silent and reported success.
SURVIVEDand "this code path has no test" are the same string, and only one of them was true. What caught it was disbelief — the surviving mutant looked like it should have been caught by a test that named uniqueness in its own title — not the tooling.
And the disbelief was half right, which is the second finding. Once the cache was cleared, one
mutant still survived: relaxing already applied from "at exactly one position" to "somewhere"
killed nothing, while the same relaxation on the clean branch was caught at once.
::test_a_hunk_whose_context_matches_twice_is_a_conflict covered one branch of a two-branch rule and
read, from its name, as though it covered the rule.
::test_an_already_applied_hunk_matching_twice_is_a_conflict_too is the missing half — a user who
adopted a change and kept a second copy of the block — and it was written because a mutant demanded
it, not because anyone re-read the predicate.
MEDIUM — the invariance test demanded the wrong property, and passing would have been worse than
failing. "A user's edit never appears in the report" was written as "the two outputs are
byte-identical", and it failed: a user's provider = "fastembed" does appear, as unchanged
context, because the context is what their own manifest renders to. The property that matters is
narrower — their edit never appears as a +/- line — and the first version would have forced the
implementation to hide context lines to satisfy a test, which is the tail wagging the dog. The
distinction is now in the test's docstring: the context lines are the user's, the changed lines are
the template's.
MEDIUM — textwrap broke a command across two lines, and the wrap was added for readability.
The first wrapped remedy printed run `pnk at the end of one line and init` on a throwaway
directory at the start of the next — a remedy naming a command nobody can copy, produced by the
change meant to make remedies easier to read. _fill now glues the spaces inside a code span
before wrapping. Worth remembering wherever prose meant for a terminal contains something meant to
be typed.
LOW — the plan predicted a mutant would kill two tests and it killed one, because the implementation is stronger than the plan assumed. Dropping uniqueness was expected to fail both the twice-matching test and the reordered-manifest one. It fails only the first: reordering is caught by matching the hunk's lines contiguously and in order, which is a different clause. A separate mutant — every line merely has to be present somewhere — kills the reordering test. The plan's prediction assumed a looser predicate than the one built; the two tests assert two properties, which is what it was really asking for.
HIGH — the "writes nothing" test could not see a write, and its docstring stated the reason
backwards. T3's central claim is that the command writes nothing, and the plan names the mutation
that proves it: open the manifest for writing and confirm ::test_nothing_under_the_kb_is_written
fails. It did not fail. The snapshot compared the bytes of the files only, so a rewrite of
identical content was invisible, and directories were filtered out, so mkdir(".pinakes") was too.
Both mutants survived and the suite was green.
Two things made it hard to see, and the second is the lesson.
- The mutation run had reported this mutant as KILLED, because the mutant I wrote also dropped a stray file — a strictly stronger mutation than the plan's. A mutant that does two things cannot tell you which one the test caught.
- The helper's docstring asserted the opposite of the truth: "Bytes rather than mtimes: an mtime comparison passes for a rewrite of identical content." Mtime is precisely what catches that; bytes are what miss it. It read as a considered trade-off, which is why nobody re-derived it. A wrong reason stated confidently is worse than no reason, because it ends the inquiry.
The snapshot now compares the path set, the bytes and st_mtime_ns, over files and directories
alike, with a table naming what each of the three is blind to on its own.
HIGH — the already applied predicate asked a whole-file question about a per-hunk fact. It
tested whether the hunk's removed lines occur anywhere in the manifest. A manifest is
comment-dense and repeats blank lines and bare # everywhere, so any hunk deleting one could never
be already applied: the user who adopted that change by hand was told conflicts — and under
T4's all-or-nothing rule, that refuses their whole --apply run. It now asks whether the hunk's
before image is still there, which is the same question scoped to the hunk's own region, with the
pure-addition case carried by an explicit not removed guard where a reader can see it.
MEDIUM — a control test passed under the very mutant it was written to kill. The first version of "a deletion not yet applied is still clean" deleted a line in the middle of the file. With trailing context, removing a line breaks the after image's contiguity on its own, so the first half of the predicate answered and the half under test was never consulted. Only a deletion at the end of the file — where there is no trailing context — reaches it. The shape of a fixture is part of what it tests, and "it deletes a line" was not specific enough to be a test of anything.
MEDIUM — _range and _section were written by hand and asserted by nothing. Six mutants
across them survived: emitting a two-number range where a one-line range is bare, dropping the
empty-range back-off, an off-by-one on the start, _section returning None for everything,
reading the wrong side, and dropping the column from the listing. The diff is the part of this
command a user may paste into patch, and its numbers had no test. What looked like coverage was
assert "[budget]" in out — satisfied by the diff body's own context line, whatever the listing
said. The range check is now a property test against difflib.unified_diff rather than literals,
so it cannot drift with the fixture.
MEDIUM — one message, two copies, nothing watching. cannot compare was byte-identical in
doctor.py and upgrade.py, with a docstring in the second saying "deliberately the same message
pnk doctor prints" — a convention with no gate, which is the shape F1 already is in this same
plan. It now lives in template.py beside the archive it describes, with a test that calls both
surfaces and compares.
METHOD — a scratch copy of this repo that includes .venv runs the original worktree's code.
Reported by the reviewer against its own first attempt: rsyncing the tree and running pytest in
the copy reported all 29 mutants surviving, including "always return CLEAN". uv run python3
-c resolved the copy's source correctly while pytest did not, so a spot-check of one mutant
would not have exposed it. rm -rf .venv && uv sync --frozen --all-extras in the copy is what makes
the harness honest. Together with the __pycache__ finding above, the rule is one rule: a
mutation harness must prove it can kill something before its silence means anything — run a known
mutant first, and treat a run with no kills as a broken harness rather than a clean bill.
MEDIUM — three of the first review's own fixes shipped with no test, in a commit whose message
said each had one. The second pass reverted them one at a time and the suite stayed green: the
summary line's nouns, the cannot compare: prefix on the no-template path — which docs/CLI.md
publishes as a scriptable contract, "every one of them opens with cannot compare:" — and the
conflict trailer that had just been rewritten to stop asserting a cause it cannot know. A fix
applied under review inherits the confidence of the review and none of its scrutiny. The rule
that follows: a review's fixes get the same treatment as the code they fix, mutation included.
The same pass found the wrapper's glue restore untested — dropping .replace(_GLUE, " ") printed a
private-use codepoint into the middle of run `pnk init` on the one message every KB in
existence receives, with 31 tests green.
MEDIUM — a test written to hold a fix could not fail. _TABLE was tightened to stop a
multi-line array's continuation line being reported as a table, and the test written with it
wrapped include = [ over three lines. A wrapped array of strings has continuation lines opening
with ", so both the loose and the tight pattern answered [sources] and the test passed under
either. It is now a unit test whose fixture is an array of arrays, which is the only shape that
discriminates — and the tightening turned out to have lost [[links.kb]] and [budget] # caps,
two legitimate headers, which nothing had noticed either.
MEDIUM — a new test loaded real model weights, and CI could never have told us. The test
comparing pnk doctor and pnk upgrade called the whole diagnose() on a fixture naming the real
sentence-transformers provider. On a checkout with pinakes[st] it downloads weights, takes
three seconds and dies on a FutureWarning that filterwarnings = ["error"] turns into a failure.
CI's matrix is [light], [light,pdf] and [light,pdf,claude] — it never installs [st], so
this would have been green on every run and red on the machine of anyone who had the default extra
the README recommends. Naming a provider nothing registers keeps the report offline and instant.
And the plan is a document a later increment builds from, so its errors are not cosmetic. The second pass found T3's own specification still carrying the whole-file predicate this increment had just proved wrong, and the exit-criteria block still instructing "compare bytes and the path set, not mtimes" — the inverted sentence whose implementation let this increment's named mutation survive. T4 reads that page next. Both now carry a dated correction rather than a silent edit, because the wrong version had already been built once from the words as written.
MEDIUM — the remedy named the newest archived version where the sentence means the oldest, and
one archived version is why nobody saw it. "A KB stamped from X or later is compared
automatically" printed archived[-1]. With a single archived version [-1] and [0] are the same
string, so the message read correctly on every KB that exists — and would have started telling
covered users they are not covered at the next template bump, on both surfaces, in the message
this project calls the path 100% of KBs take. A one-element collection makes two different
intentions indistinguishable, which is the same reason T1's gate leg (ii) is vacuous today and
says so out loud. The test that pins it uses the fixture that archives two.
MEDIUM — the correction broke the thing it was correcting. Pass 2's ⚠ blockquote about the placement predicate was inserted between rows 1 and 2 of the predicate table. In Markdown a table block ends at the first non-row line, so rows clean and conflict rendered as literal text inside the blockquote: the rule T4 must build appeared on the published site as a one-row table. It was caught by rendering the file, not by reading the diff — and the diff looked right, because the words were all correct and in a sensible order. A correction to a document is a change to that document and needs the same reading; the fact that it is a correction is why it gets less.
LOW — three assertions compared a payload against the enum that produced it.
payload["outcome"] == Outcome.NO_BASELINE.value is green under any rename of both sides, which is
exactly the rename that breaks a consumer's parser. Eight JSON strings lived in that gap. The
replacement writes the keys and values out as literals, once, so a wire change is a visible diff in
a test rather than a silent break in somebody's script. A test that derives its expectation from
the code under test is a tautology wearing an assertion's clothes — the same shape as the
"[budget]" in out assertion two passes earlier, which the diff body satisfied.
HIGH — three edit batches aborted partway and I reported all three as landed. The fixes were
applied by scripts that walked a list of (file, old, new) and wrote each file as they went. When
an anchor failed to match — because an earlier edit in the same run had already changed the text —
the script raised, leaving the files before the failure written and the files after it
untouched. Six edits were lost that way: the assertion pinning the oldest-archived-version fix, the
up to date branch assertions, three of the four cannot compare: prefix assertions, a
docs/KB-UPDATES.md contradiction and a docs/RETROSPECTIVES.md heading. Two of them were the
headline fixes of the commit that claimed them, and the commit message, the retro fragment and
docs/VERIFICATION.md all said they were pinned by a named test. The next pass found the test
asserted nothing of the kind.
Three rules came out of it, and the third is the one that would have caught this on its own.
- An edit batch is all-or-nothing. Resolve every anchor first, write nothing until all of them match, and name the file and the anchor when one does not.
- Never let one batch depend on text an earlier edit in the same batch produced.
- A claim that a fix is pinned is a claim about a failing test. Revert the fix and watch the suite go red, or do not write the word "pinned". Four separate fixes across three passes were described as pinned by tests that were green without them — and each was found by the next reviewer doing the revert I had not.
MEDIUM — an assertion can be true because the current wording is kind. "pnk init appears
unbroken in the output" passed with the code-span protection removed, because at this width the
remedy's spans happen not to straddle the wrap column. It was a real assertion of a real property
that simply could not fail on this input. The property belongs to the wrapper, so it is now tested
against the wrapper, with a fixture built so that plain textwrap provably splits the span —
and a second assertion that checks the fixture still has that property, because otherwise the test
degrades into the one it replaced the moment someone edits the string.
Where the review loop stopped, and what each pass cost. Five adversarial passes, finding 30 → 22 → 13 → 6 → 1. The fifth found no defect in shipped behaviour — one documentation overstatement and seven coverage gaps in code that was correct — which is the signal the loop was waiting for. What the shape of that curve says is worth more than any single finding: the first pass over an increment is not the expensive one. Passes 2 and 3 each found that a previous pass's fixes were wrong or untested, and pass 4 found the tooling that had lost them. A review that stops at one pass stops before it has reviewed anything it changed.
The classes, in the order they cost the most:
| Class | Instances | Why it survives a careful reading |
|---|---|---|
| A fix claimed as "pinned" whose test is green without it | 4 | The claim is about a failing test, and nobody runs the failure |
| A test that cannot fail on its own fixture | 3 | It asserts a real property of a real surface; only the input is wrong |
| A correction that broke what it corrected | 2 | The words are right, so the diff reads right |
| A whole-file question standing in for a per-hunk one | 1 | Both are true on the fixture that motivated the feature |
T4 — pnk upgrade --apply (20260808 05:14)¶
MEDIUM — a plan can specify a test that cannot discriminate, and the name is what hides it.
T4's plan named ::test_a_key_carried_only_by_a_conflicting_hunk_is_not_recommended as the
load-bearing operand test: the one that separates parse(base + applied) from parse(ours). It
cannot. Under the same plan's all-or-nothing rule a conflicting run refuses, writes nothing and
prints no recommendation at all — so both operand choices produce an identical observable, and the
test would have passed under the wrong implementation while reading as the proof that it was
right. The case that actually discriminates is an already applied hunk: skipped, its key
already in the file, and therefore not something this run introduced. Written as
::test_a_key_carried_only_by_a_skipped_hunk_is_not_recommended. The plan's own ground rules say
every test name in it is a prediction and not binding; what this adds is that the scenario can be
a prediction too, and a scenario nobody re-derives is a test that measures nothing under a name
that says otherwise.
MEDIUM — the consent predicate had a fourth near-miss the plan did not enumerate. D-10
requirement 2 lists three cases where a spending-cap heading must not appear — an already-applied
budget hunk, a conflicting run, and a bump touching no [budget] line — and specifies the
predicate positionally: a clean hunk falls inside [budget]. The shipped template's only real
[budget] drift (M3) rewrote three comment lines as well as two caps, so a hunk that moves
only comments is inside [budget], applies cleanly, and moves no money. Positionally it would
print a spending-cap heading with nothing under it — the fourth near-miss, and the one most likely
to recur, because comment churn is what template drift mostly is. The predicate shipped as a
clean hunk inside [budget] that changes at least one key, still structural and still with no
key-name list, plus its own negative control. The plan's requirement 2 argues for exactly this and
its three-case list is what fell short of its own reasoning.
LOW — an open correction that names a fork resolves to both, and saying which matters. The CRLF item required T4 to "either preserve each line's ending or refuse a manifest whose endings are not uniform, and say which". The answer is both, and the split is not a hedge: a uniform CRLF file is preserved, because that is an ordinary Windows manifest and rewriting it would be a change nobody asked for; a mixed file is refused, because it is already the product of two tools disagreeing and picking a winner silently rewrites lines the user never touched. The report path is unaffected either way, since reporting reads.
LOW — a refusal ordering decision that only shows up on the second run. Content refusals (a
conflict) are checked before environmental ones (a stray .orig, a held lock). The other order is
defensible until you follow it through: it tells a user to clear a backup file so that the retry
can tell them their manifest conflicts anyway. Two round-trips to deliver one permanent fact.
The mutation record, because "pinned by test X" is a claim about a failing test. Eleven mutants run 20260808 05:14; each is the code broken on purpose and the tests that noticed, and two of them are here because the first attempt measured nothing:
| Mutant | What went red | The control that had to stay green |
|---|---|---|
| remove the conflict refusal | …refuses_entirely_when_any_hunk_conflicts (+2) |
— |
write the .orig before the conflict check |
the same test, on assert not _backup(root).exists() — checked by reading the assertion that fired, not the test name |
it must not fail on byte-identity instead, or the ordering is not what is pinned |
| remove the re-parse | …unloadable_manifest_is_rolled_back, alone |
— |
| drop the rebuild-naming line | …names_the_rebuild…, alone |
…does_not_run_a_sync stayed green — that pair is the whole point of finding it |
| a key-level writer instead of the hunk applier | …the_comment_the_template_added_is_present_after_apply (+2) |
— |
| suppress the spending-cap heading | both-values ×2, ordering | …budget_hunk_is_applied_like_any_other_hunk stayed green; if it goes red the heading tests are entangled with the write |
| print the heading unconditionally | all four negative controls (+4) | — |
| print the new value only | both-values ×2, on the old value's absence | — |
| write before any printing | ordering | both-values stayed green — which is exactly why ordering needs its own test |
write requires_pinakes |
…is_never_written (+2) |
— |
| recommendation over every hunk, not the applied ones | …carried_only_by_a_skipped_hunk…, alone |
the other three requires_pinakes tests pass under the wrong operands, which is what makes that one load-bearing |
Two of the eleven were measured wrong on the first attempt, and both failures looked like
success. The syntactically-invalid mutant made pytest print ERROR, not FAILED, and a harness
counting FAILED lines read that as nothing noticed — the mutant survives: a false negative
that would have sent someone to strengthen a test that was already correct. And the .orig
ordering mutant turns twenty-two tests red, so the run "failing the right test" says nothing until
you read which assertion fired. A mutation harness needs its own controls for the same reason
the tests do.
MEDIUM — the first adversarial pass found five defects, and four of them were invisible from the tests because every one of them is a property of the write, not of the diff. T4's whole test surface was built from a plan concerned with placement, consent and refusal; none of those notice what happens to the file's inode.
| Found | Why it matters |
|---|---|
str.splitlines() breaks on \u2028, \u2029 and \x85; split("\n") does not |
the report and the writer would disagree about which lines the file has, so a hunk the report called unique could match somewhere else, or nowhere. All three are non-ascii under TOML's own comment grammar, so a manifest can carry one and still load. Refused, because rejoining on \n would silently turn it into a newline in a file the user owns |
os.replace onto a symlinked pinakes.toml destroys the link |
the real manifest is left elsewhere holding the old text, and the KB now has a regular file. sidecar.write had already learned this and resolves the same way — the lesson was in the repository and the new writer did not inherit it, which is the argument for the write helper being shared rather than re-written |
mkstemp creates 0600, so the rename narrowed the manifest's permissions |
a user who had made it group-readable loses that, silently, on an unrelated command |
| a dotted key parsed to its first segment | budget.monthly_eur = 30.00 would be announced as a key called budget — in the consent line, which is the one output that must name what it is asking about |
| the rollback wrote non-atomically | it restores the file after a failed write; doing that non-atomically reintroduces the failure mode it exists to recover from |
All four fixes were then re-mutated, and each turns exactly one test red. The fifth has no
test and is not claimed to have one: a crash mid-restore cannot be staged in-process, and a row
in docs/VERIFICATION.md says none rather than pointing at a test that would pass either way.
HIGH — the second pass found the one defect that would have printed a false spending-cap
heading, and it was a correct function used one scope too wide. Hunk.section is read out of
base by scanning backwards from the hunk's first changed line, so it is the table the hunk
starts in. That is right for the placement listing, which labels a region. It is wrong for
attributing a key, because a hunk that adds a whole new table carries keys belonging to a table
base does not contain — and every one of them was credited to the preceding table. A template
adding a table directly after [budget] would have had each of its keys announced under a
spending cap changes, and one added after [chunking] would have invented an index rebuild.
Two things are worth keeping about how it was missed. It is not a bug in _section; it is a
value being reused at a scope where its definition no longer holds, which no test of _section
could ever catch. And every consent test passed, because each fixture's hunk stays inside one
table — the negative controls guard against a heading printed when nothing moved, and this prints
a heading naming the wrong thing, which is a different failure they were never shaped to see.
LOW — two refusals in splices were unreachable from every fixture, so they shipped unseen.
One is unreachable by construction (_placement established uniqueness over the same text moments
earlier) and the other needs a manifest repeating a region in a shape difflib still calls two
hunks. The function is public now, for the reason fill and restamp are, and both guards have a
test that reverting them turns red — including a control on the overlap guard, since a refusal
that fires on everything passes the same assertion as one that fires correctly.
MEDIUM — and committed in this increment, in the file that documents the mistake. Pass 2 was
committed with ./check.sh 2>&1 | tail -2 && git commit. In a pipeline the shell reports the
last command's status, so tail succeeding made a failing ruff invisible and the commit
landed over a red gate. check.sh's own header comment describes this exact shape as the reason
the script exists — "uv run pyright | tail -1 && git commit reports the tail exit status, so a
failing checker looks green" — and it was reproduced by a reader of that comment, one line below
it, while running that script. A gate is only a gate when its status is the one the next command
reads: write ./check.sh > log 2>&1; echo $?, never ./check.sh | tail.
LOW — pass 3, and both findings are the first two fixes not being finished. Writing through a
symlink (pass 1) puts the backup beside the file it backs up, in another directory — and the output
still printed its bare filename, which is true of every ordinary KB and misleading in the one case
where finding the file takes work. A fix that changes where something lands has to be followed into
whatever names it. And docs/CLI.md's refusal row still listed five causes when the code had
eight, because passes 1 and 2 each added a refusal and neither went back to the table that
enumerates them.
The same-manifest gap is stated rather than closed, deliberately. A version bump that leaves
the manifest byte-identical produces no hunks, so --apply writes nothing — the [kb] template
restamp included — and that KB keeps reporting drift with no way to record the new reference.
T4 specifies --apply in terms of hunks and there are none, so writing anyway would be behaviour
the plan does not describe. It is a test asserting today's behaviour, a row in
docs/VERIFICATION.md, and an item proposed for plans/20260731_1202-open-corrections.md — not a
decision taken quietly inside an increment.
MEDIUM — pass 4 found nothing in the code and three falsehoods in documents the increment never
opened. docs/README.md still said "what is still a proposal is --apply"; docs/KB-UPDATES.md
§1 said adoption "is still deferred" and its drift-axis table said "Adopting it is still
absent: nothing writes the change into a user's manifest". All three were true when written and
false the moment T4 compiled, and none is in any file T4 touched — which is exactly why the
audit-the-neighbourhood convention exists and why a diff-scoped review cannot satisfy it. The
search that found them was two greps over docs/ for the claim being falsified ("writes
nothing", "--apply" near planned/deferred/absent), not a re-read of the change.
And the counterpart worth stating: docs/ROADMAP.md's ## 0.19.0 section still says "It writes
nothing" and was left alone. That section is a record of what 0.19.0 shipped, and it was true of
0.19.0. A release-notes entry rewritten to match a later release stops being a record. The line
that does need the sweep is the page's own "Where things stand right now", which is
current-state and is the release's job, not the increment's.
Every documented exit code was then run rather than read — report 0, --apply 0, a
conflicting report 0, a conflicting --apply 1, cannot compare under --apply 3 against
the shipped notes, and an up-to-date --apply 0. Six rows, six commands, matching the table in
docs/CLI.md.
Pass 5 found no new defect and is recorded anyway, because what it did is the part usually
skipped. It checked the two factual claims the increment writes into three documents and one
runtime message — that upgrade.apply is the only thing rewriting a pinakes.toml after
pnk init (init.py:88 is the other write; nothing else in src/ writes it), and that init's
.gitignore covers .pinakes/ and nothing else. Both hold. Then it mutated the seven T4 tests no
earlier mutant had killed. Every T4 test is now killed by at least one mutant — which is a
weaker claim than "every test asserts its property", and it is the strongest one a suite can
actually make about itself.
T5 — The plan asked for a decision the file next to the code had already taken (20260808 06:28)¶
HIGH — D-4 was open in the plan and settled in manifest.py, three lines below the tuple it was
about. The plan spent a four-option table, a recommendation and a paragraph of D-12 cross-reference
on "what happens to vector_tier = "sqlite-vec" before the tier exists", and framed the choice as
turning on a judgement about docs/MANIFEST.md: is its row a promise or a disclosure? Meanwhile
GRAPH_CHANNELS' docstring, at manifest.py:52 against VECTOR_TIERS at :51, already stated the
answer as a rule — "a manifest that can ask for a mode the code does not implement is a manifest
whose setting silently does nothing, and table.choice refusing the name is how a user finds that
out at load time" — and applied it to "ppr", a value in the very next row of the same
documentation table. The plan cites neither.
What that changes about reading a plan. A plan's decision table is a list of questions its author could not answer from the plan, which is not the same as questions the repository has not answered. Two of the plan's four open recommendations here were about consistency with existing behaviour, and the cheapest evidence for both was adjacent to the line being changed. The habit worth keeping: before weighing a plan's options, look at what the sibling key does — the file is often more decided than the document about it.
MEDIUM — the plan's own two halves disagreed about what T5 could deliver. It asked for
resolve_tier to be called by both sync and search, "so meta's claim and the code path
cannot disagree", and then admitted two paragraphs later, correctly, that "with exactly one real
tier there is nothing else to discriminate". Both cannot hold: if there is nothing to discriminate,
search has no dispatch to make, and a tier parameter threaded into _vector that can hold one
value behind an unreachable branch buys a shape that looks like a shared decision while being
decoration. Built the resolver with one caller and said so in its docstring. This is the eighth of
the template-release plan's own measurements or specs to be wrong, and the second found by building
rather than by reading it.
A smaller one, on honesty in a one-tier world. The first draft of resolve_tier was
return "numpy" — correct, and a function that ignores its only argument. It became
return "numpy" if tier == "auto" else tier, which reads the manifest and honours an explicit
tier. Today both arms return the same string, so no test can tell them apart; what the second form
buys is that the increment restoring "sqlite-vec" gets it honoured by that line and owes only
auto's side of the choice. Worth the branch; not worth pretending a test covers it.
T7 — pnk templates, and a template declares its files (20260808 09:54)¶
HIGH — a test with two cases in one body only holds the case that runs first.
test_a_template_file_entry_that_escapes_the_target_is_refused covered both write-side escapes:
../../evil.md and a symlinked directory in the target. It was green, and the mutation pass showed
it was green for the wrong reason. Removing the destination containment check turned it red on its
first assertion — ../../evil.md escapes the template as well as the target, so the other
layer caught it with a different message — and pytest stopped there. The symlink case, the one
only the removed check can catch, never executed under the mutation it existed to detect. Split into
two tests; both now go red, and the source-side test stays green. The general shape: when two
guards can both catch one input, a test that exercises that input proves nothing about either of
them, and a shared test body hides which one fired. This is the same family as T4's
…by_a_conflicting_hunk_… — a test that cannot discriminate — but arrived from the opposite
direction: not a case that looks identical under both implementations, but a case caught twice.
HIGH — a plan's rule sentence and its test list can specify different things, and the test list
was right. T7 states that each files entry "is validated to land inside the target KB", then
lists a test refusing "a symlinked directory inside the template tree". Those are two layers: a
symlink in the template resolves outside the template while its destination stays inside the
target, so the target rule cannot catch it and the named test could never have passed. Both are
built. The write side stops an entry writing into a directory the user never pointed Pinakes at; the
read side stops it copying something the template does not own into a KB that is then committed
and published — which for a public repo is the more expensive direction. Read a plan's test list as
part of its specification, not as an illustration of it. Where the two disagree, the test list has
been forced to be concrete and the sentence has not.
MEDIUM — a coverage row that cannot fail is worse than no row. T7's free-path deliverable asked
for main(["templates"]) and a module added to test_paid_path.py's surface list. But pnk init
already runs in the same fixture, so pinakes.template is in the import graph regardless, and
run_templates lives in pinakes.cli — no row would have failed if the new call were deleted. The
call was added and the row was not, with the reason written at the call site so the omission is not
"fixed" later. Eight of this plan's measurements were already wrong; this is the ninth and the
tenth, and both were found by trying to satisfy its own test list rather than by reading its prose.
MEDIUM — a new listing command inherits every failure mode of the thing it lists, multiplied.
template.describe raising a bare OSError on a damaged install is an open correction from T3, and
it reaches init, doctor and upgrade. Before pnk templates, that cost you the run that
named the damaged template. A listing iterates all of them, so one bad directory produced a
traceback and reported nothing about the templates that read perfectly. The general fix stays open —
it is not this increment's to settle — but the blast radius this command introduced is contained
here: the failure is caught per template, shown as a row, and the exit code is non-zero. Adding a
"list everything" surface converts a per-item defect into a whole-command defect, and that
conversion is the new increment's to own even when the defect is not.
LOW — the runtime agreed with a bug the linter caught. A missing Callable import passed the
whole test run and failed ruff. The worktree's uv environment is Python 3.14, where PEP 649
defers annotation evaluation, so an annotation naming an undefined type never evaluates. On the 3.13
this project requires, it is a NameError at import. A green test run on a newer interpreter is
not evidence about the one in requires-python — which is what the linter is for, and why
./check.sh runs it before pytest rather than after.
HIGH — a new key can defeat an existing invariant by living in the file that invariant excludes.
tools/template_drift_gate.py hashes every file in a template except template.toml, and that
exclusion is load-bearing: hashing the file that declares the version would make every bump change
the hash by construction, so "a version bumped with no content change" could never be detected. The
gate's own limit (b) recorded the cost as the description could be edited unnoticed — true, and
cheap, while a description was the only editable thing in there. T7 added files = [...] to that
same file, and files decides which files a KB is stamped with. So the key that determines a
template's stamped content landed in the one file excluded from the check that exists to stop
stamped content changing without a version bump. Closed by folding only the list into the digest:
name, version and description stay out, so leg (ii) can still fail. An absent key contributes
nothing, which is what makes the change backward-compatible — the shipped notes hash is
byte-identical before and after, so no published _versions.toml row needed migrating. When an
increment adds a key, ask which existing invariants were scoped by the assumption that the file
holding it did not matter. Nothing in T7's plan connected the two; the link is only visible from
the gate's side.
MEDIUM — git checkout -- <file> restores to the last commit, not to the state before the
mutation. The mutation pass ran fine against template.py, whose changes were already committed,
then silently destroyed the uncommitted drift-gate work when the same restore step ran against it.
The mutation's result was valid — both target tests went red, the negative control stayed green —
but the code came back as main's, and the next full run showed two failures that looked like the
fix was wrong when it was simply gone. Commit before mutating, always: the restore step is only
as precise as the thing it restores to, and a mutation harness that quietly reverts real work
reports a false failure rather than a false pass, which is the better direction but still costs a
debugging cycle.
Open corrections 1 and 3 — a guard can route a failure into a wrong message (20260810 01:29)¶
HIGH — turning a traceback into a PinakesError moved the failure into a handler that already
existed and said the opposite thing. Item 3's whole content is stop these five reads escaping as
tracebacks. Doing it made describe raise TemplateError — and doctor.py and upgrade.py both
already wrapped their describe call in except PinakesError, answering it with "is not
installed here" and a remedy about installing the template. That arm was correct while the only
thing reaching it was a template genuinely absent; a damaged install had been going straight past
it as a traceback. So the fix silently recruited a handler written for the opposite case, and a user
whose template.toml was unreadable would have been told to install a template sitting right there.
A traceback is loud and a wrong sentence is quiet, so this is a downgrade that reads as an
upgrade. Both surfaces reported WARN, both exited 3, every test stayed green — the increment's
own tests included, because they assert the new messages and the pre-existing ones assert the
absent-template case that still works. Nothing was red. It was found by asking who else catches
what this function now raises, which is a different question from does my change work.
The correction is TemplateNotInstalledError, a subclass so that every existing except
TemplateError keeps working, with _unknown as its only raiser and the two callers splitting the
arms. Both surfaces get their own test, because the wording is a fact with one home but the routing
is a decision each caller takes for itself — one test would leave the other free to merge the cases
back.
Generalises past exceptions: widening a type is an interface change on every except upstream.
The grep that finds it is not for the function being changed but for the type it starts raising,
and the question is whether each catcher's answer is still true for the new cause. PinakesError is
caught in 30-odd places here precisely because it is the type that means print this and stop, so
anything newly raised as one inherits whatever those handlers already say.
Second, smaller, and mechanical: git checkout -- <file> during a mutation pass deletes the fix
being verified. The pass ran before the commit, so restoring after each mutation restored to
main — three mutations in, both source files were back to their unfixed state and the tests were
"failing correctly" against no fix at all. The evidence was still true and the work had to be typed
again. Commit, then mutate: BUILDING.md orders the steps that way, and this is the reason
rather than a convention.
A release sweep is table-shaped, and a narrative is not a row — 20260811 12:18 UTC¶
What happened. With every plan built out and the open-corrections list empty, the first check of
the next session was what does the repo say about itself. docs/ROADMAP.md said 0.21.0. Its
release table carried a 0.22.0 row, its per-release section carried the full 0.22.0 write-up,
and its open-corrections section said none live, all four shipped in 0.22.0 — but the two prose
blocks that state the project's position, ## Where things stand right now (stamped 20260808
06:41) and ## The template release, were three releases behind. One of them still said "T4 and
T7 are still to come" about increments that had shipped on 20260808 and 20260808.
Why it survived five sweeps. The landing checklist asks which file to edit, and the file was edited — every time. What a sweep naturally finds is the row it is adding: a table has one line per release, so the release being cut points straight at its own row. A paragraph that summarises all releases has no row to add, so nothing in the act of cutting a release makes it obvious. The per-release sections and the tables were correct at every commit; only the summaries were wrong — which is the worst arrangement, because a reader checking one against the other finds agreement in five places out of six.
The second instance is sharper. docs/README.md's plan-routing table — the table whose whole
job is to tell a session which plan is live — had no row at all for
plans/20260811_0720-decisions-gates-and-corrections.md, the plan CLAUDE.md names as the live
build order and the authority for eight decisions. The plan was written, its six increments were
built and landed, and the index of plans never learned it existed. A missing row is invisible to
every check that reads rows; only asking "is everything that exists listed here?" finds it, and
that question is not part of landing an increment.
What generalises. "Update the doc" and "update the doc's summary of itself" are different actions, and only the first is prompted by the work. A document that both enumerates and summarises will drift at the summary, in the direction of the last release that bothered to rewrite prose. Two checks are worth adding to a landing:
- Grep the docs for the previous version number after a release, not just for the fields the
checklist names.
grep -rn "0\.21\.0" docs/would have found both ROADMAP blocks in seconds. - Ask what is missing, not only what is wrong. The routing table's defect had no wrong text to
find. Reading
ls plans/against the table is a ten-second check that no diff review performs, because nothing in a diff is absent.
And the check found more than it was looking for. Auditing the neighbourhood surfaced that the
20260807 audit's 40 documentation corrections have never been worked — the file has one commit,
the one that created it — and that the same audit deferred a full review of docs/ROADMAP.md until
after T2, which shipped in 0.18.0. Neither is visible from any release's own sweep either.
A row can be complete, correct, and in the wrong place (20260811 13:27)¶
HIGH — five release rows were out of order in three sequences, and nothing could see it.
docs/ROADMAP.md's release table and its per-release sections both read
0.20.0, 0.22.0, 0.22.1, 0.21.1, 0.21.0, 0.20.1; docs/STATUS.md's roadmap table put 0.15.1
after 0.16.0 and 0.20.1 after 0.22.1. Every misplacement is wrong on both readings —
SemVer and release time — so no ordering convention made them right. CHANGELOG.md was checked
and is clean, headings and link definitions both.
Ordering is a property of the sequence, not of any row in it. That is why nothing caught it:
the tables are complete, every anchor link resolves, mkdocs build --strict is green, and a reader
checking any single row finds it correct. Every check this project owns reads rows.
How it happened, recovered from the six release commits. 0.20.1 was appended correctly
(2da0e07). 0.21.0 (96b3b35) then inserted its section one position too early — after
0.20.0's rather than after 0.20.1's — and the next three sweeps (c83e877, df832fe,
93c20ab) each used that same slot.
| Commit | Release | Section tail after it |
|---|---|---|
d429a2c |
0.20.0 | … 0.19.0 0.20.0 |
2da0e07 |
0.20.1 | … 0.20.0 0.20.1 ✅ |
96b3b35 |
0.21.0 | … 0.20.0 0.21.0 0.20.1 ← the first error |
c83e877 |
0.21.1 | … 0.20.0 0.21.1 0.21.0 0.20.1 |
df832fe |
0.22.0 | … 0.20.0 0.22.0 0.21.1 0.21.0 0.20.1 |
93c20ab |
0.22.1 | … 0.20.0 0.22.1 0.22.0 0.21.1 0.21.0 0.20.1 |
The tail was locally self-consistent at every step, which is the whole lesson. After the first error it read strictly newest-first, so each following sweep saw a coherent pattern around its own edit and matched it — reproducing the error is what reading the neighbourhood carefully produced here. Only the join between the ascending head and the descending tail was wrong, and that join is a single line no sweep's diff ever touched. Read the sequence, not the neighbourhood.
It had already been found and left. The 20260807 documentation audit's finding
docs/STATUS.md:303 names the 0.15.1 row exactly, with PyPI upload times as evidence. It sat
unworked for four days, and in that window three more sweeps added three more misplaced rows. A
verified finding nobody schedules is worth what an unverified one is.
The fix is a gate, not a checklist item — tools/release_order_gate.py, in check.sh and CI,
on the threshold this project already applies (status_header_gate.py: a checklist missed it four
times). Two design points are the reason it will still work in a year:
- Direction is declared per sequence, never inferred. Inferring it from whichever way most adjacent pairs agree would let a badly scrambled file elect its own answer and pass.
- A sequence shorter than a floor is a failure, not a pass. An empty sequence is sorted by definition, so a pattern that silently stops matching — a reformatted table, a changed heading style — is exactly how this class of gate dies quietly. A test asserts the real documents clear that floor, so a reformat goes red in the commit that does it.
Watched failing before it was trusted: run against the pre-fix tree it names all nine misorderings and exits 1; CI scrambles a copy and asserts both the exit and the stated reason.
E1 — pnk ask, and the output nobody could check (20260811 15:17)¶
MEDIUM — a doc that pastes command output goes stale silently, and nothing in this repo can see
it. docs/GUIDE.md § Searching showed a pnk search result whose last two lines were the
escalation notice — Paid synthesis (pnk ask --deep) is planned for the deep release. E1 changed
that sentence in cli.py, and the pasted block kept printing the old one. It would have shipped:
./check.sh is green on it, mkdocs build --strict is green on it, and every link in the file
resolves. The conventions already say verify a doc by running the commands it shows
(docs/README.md) —
this is the first recorded instance of the rule
catching something, and it caught it because the increment happened to edit the very sentence, not
because anything checked.
What makes this class different from a stale sentence. Prose describing behaviour is written by
someone who read the code; pasted output is a measurement, and a measurement has no author to
notice it aged. There are three such blocks in GUIDE.md and two in CLI.md. Nothing pairs any of
them with the code that produces it.
Not turned into a gate here, and the reason is worth recording. A gate would have to run each
documented command against a fixture KB and diff the output — which is a golden-output test on
formatting, the most brittle kind, and it would fail on every deliberate wording change with no way
to tell those from regressions. What is cheap and was done instead: both new blocks in this
increment were pasted from a real run (tests/demo-kb for the calibrated one,
tests/partner-kb for the uncalibrated one), so at least the version now on main was true once,
with the KB that produced it named beside it.
LOW — a test that can only fail in a later increment must say so in its own docstring.
test_a_confident_kb_gets_cited_evidence_and_the_price_of_one_call asserts no ledger.jsonl exists
after pnk ask. Nothing in this build can spend, so the assertion cannot fail today; it is a
tripwire for E4, which adds a paid loop to this same command through the shared _retrieve. Left
unexplained it reads as dead weight and the next reader deletes it — one increment before the one
it was laid for.
LOW — the vocabulary was spelled twice in one file. run_search compared confidence against
the string literals ("low", "unknown") while _escalation, forty lines below, compared against
search.py's LOW/HIGH/MEDIUM constants. Both correct today. The renderer also asked
if escalation.branch != "none" to decide whether to name the deep release, putting the branch
vocabulary in a third place — and the decision that actually mattered (never offer to answer a
question nothing matched) lived in none of them. Both fixed: the constants throughout, and the
notice carried on the value rather than re-derived at the print site.
E2 — The deep release's round estimator (20260811 16:15)¶
HIGH — the worst case left out the one input the user controls: the question itself. The plan's
formula prices carried memory, final_k passages and a prompt constant, and the first draft
implemented exactly that, with the question folded into PROMPT_TOKENS. But a question arrives as
an argv string — pnk ask "<question>" — with no length limit anywhere in the CLI, and it is
carried into every call of a run, so a 50,000-token question would have been reserved for at
1,500 tokens. Fixed by pricing it separately (QUESTION_TOKENS) against a stated ceiling
(QUESTION_CHAR_CEILING) that E4 must enforce, since this module refuses nothing but a stale price
table and an oversized request. Lesson: when a worked example pins an estimate to the cent, it
also silently fixes the size of every input the example did not vary. The question was the one term
with no bound anywhere in the system, and the arithmetic looked right because the example never
made it big. Ask what is in a call's input that the formula does not name — not whether the formula
was implemented correctly.
HIGH — the second review pass found a ceiling below its own measurement, in a module written to
refuse exactly that. PASSAGE_ENVELOPE_TOKENS carried the comment "the longest path —
heading_path pair is under 120 characters", asserted without running anything, and 100 tokens was
sized from it. Running it — by extending tools/measure_passage_tokens.py to report the envelope
as well as the chunk — returned 220 characters, about 110 vendor tokens at the pessimistic
conversion the module's other constants use. The constant was under-reserving against a number
nobody had taken; it is now 250, with the measurement and its date beside it. Lesson: the review
question that found it was "which of these numbers did I measure, and which did I merely write?" —
asked of a file whose every other constant carries a command. A measured neighbour makes an
asserted one look measured too, and prose like "measured from the corpora above" is how the two
become indistinguishable.
MEDIUM — a total divided by its call count and multiplied back was not the total. Decimal
division is exact to 28 significant digits and no further, so per_call_eur * calls landed one
digit above total_eur at the shipped defaults — meaning, in the other direction, a per-call
reservation could sum to less than the operation it belongs to. Caught by a test asserting the two
were equal. Fixed by deriving the total from the per-call price rather than the per-call price
from the total, which removes the class rather than the instance: no set of constants can
reintroduce it. Lesson: Decimal fixes base-10 representation, not associativity — money code
should multiply a unit price up, never divide a total down, and the direction of the last digit is
chosen by nothing.
MEDIUM — a "measured" docstring falsified by the fix in its own increment. The docstring above cited the two 28-digit values it was measured at; the fix changed the arithmetic that produced them, so re-running the same comparison now round-trips exactly and the cited numbers describe code that no longer exists. Rewritten to say what was observed and when it stopped being reproducible, rather than left reading as a claim about the shipped module. Lesson: the measurement most likely to go stale in a docstring is the one that motivated the change in the same diff — grep your own increment's fixed defects for the numbers that justified them.
LOW — a purity test written as a denylist, in a repository that had already learned not to. The
first version asserted deep/estimate.py imports no anthropic, sqlite3, httpx, requests —
a list that is never finished, and whose next omission is always the one that matters.
check.sh's NUL scan records the same lesson about file suffixes ("a denylist of binary formats is
never finished"), one file away. Rewritten as an allowlist of what the module may import, so a new
import has to be argued for in the test. Lesson: for "this module stays pure", enumerate what is
allowed. The repository's existing gates are worth reading for the shape of the assertion, not only
for the rule.
A finding handed to E4 rather than solved here. At the shipped defaults (final_k = 8,
[chunking] max_tokens = 510, [budget] per_operation_eur = 0.30) the cheap branch prices at
EUR 0.2627 and fits inside the cap; a five-round loop prices at EUR 2.81 and is 9.4x it. So on a
stock KB, pnk ask --deep would answer a confident question and refuse an uncalibrated one at
round 0 — which is precisely the combination D-22 option E was chosen to avoid, arrived at through
the caps instead of through the signal. E2 declines to fix it by lowering a ceiling: that is the
trade PAGE_TOKEN_CEILING refused, and the numbers are conservative by design until E6 measures
them. It is pinned as
tests/test_deep_estimate.py::test_the_shipped_defaults_leave_the_loop_outside_the_default_operation_cap,
reading both sides out of the manifest defaults so it tracks them rather than restating them.
E3 — The deep client, and four rules that were about to be copied (20260811 17:12)¶
HIGH — a second paid entry point is where a "one home" rule quietly becomes two. E3's section of
the plan says the key is PINAKES_ANTHROPIC_API_KEY and never an ANTHROPIC_API_KEY fallback,
"enforced the same way here" — a sentence that reads equally as reuse it and as write it again.
Writing it again would have been the smaller diff, and it would have produced two copies of four
rules that each fail silently when they drift: the key's name, max_retries=0, whether a failed
call billed, and how a reconciliation is computed. CLAUDE.md calls an ANTHROPIC_API_KEY fallback
"the same defect, one layer apart"; a second copy of the rule forbidding it is the same defect one
file apart. So src/pinakes/paid.py exists, holding all four with no client in it, and
extract/claude.py now imports what it used to declare. What made the move safe rather than brave
was that its 1,606-line suite is unchanged: the only test that failed was the allowlist count, which
is the gate refusing the widening until it was declared.
The module is deliberately not allowlisted, and that is the interesting part: classify is
handed the caller's already-imported SDK module rather than importing one, so gate 2 scans it like
any other file and would refuse an import anthropic added to it. The allowlist stays two entries —
extract/claude.py and deep/client.py — which is the complete list DESIGN §1 describes.
HIGH — the shared classifier had no direct test, and it decides void versus unknown outcome.
Moving it surfaced that every branch of _classify had only ever been reached through a fixture
that raised an already classified error: the replayer constructs TransportError itself, so the
mapping from an SDK exception to a billability had never been executed by a test in either suite.
The relationship it turns on is the one stubs/anthropic.pyi warns is easy to get wrong from
memory — APITimeoutError is a subclass of APIConnectionError, so checking the parent first
classifies every timeout as not-billed and voids a reservation for a call that may have been
charged. It now has five tests against a fake SDK whose hierarchy is held against the stub's, and
against the real package where it happens to be installed.
MEDIUM — the renderer was spending 90% of a ceiling on an identifier the model never emits. The
first draft of render_passages mirrored cli.py's printed block exactly, citation line included —
which carries the path and the heading a second time. E2 measured PASSAGE_ENVELOPE_TOKENS
against one copy (220 characters, the widest real passage in the corpora) and set it at 250 with
2.3x headroom; two copies came to 452 characters, 226 of the 250 reserved tokens, leaving
nothing for a KB whose headings run deeper than the two corpora measured. Found by writing the test
that pins the arithmetic, not by reading the code. The fix was to drop the citation line rather than
raise the constant — E2's own rule, and PAGE_TOKEN_CEILING's: a ceiling that close to its own
measurement is not a ceiling. The test now asserts the envelope stays under half the constant,
because "inside it" was true of the draft too.
MEDIUM — three smaller findings from the same pass, each a disagreement between two halves of one
bound. The subproblem cap was computed twice, once for the request's schema and once for the
parser that re-checks the response — the shape where the second check silently becomes about a
different limit than the first, at which point one of them is decoration (now subproblem_cap, one
function, driven through both users by one test). answer_schema wrote max(passages, 1), so a
call over zero passages would have declared citation [1] legal to the API and had every index
refused by the parser — prose with nothing behind it, paid for; both halves now refuse
(NoEvidenceError), which is E2's reason for not pricing the none branch, arriving one layer
down. And billed_call voided on any exception the transport did not classify — but a defect is
not proof the call never billed, and proof is what INVARIANTS requires before a void, so an
unclassified failure is now left unresolved instead.
LOW — a test that asserted its own base class. The first version of "every error carries a
remedy" checked issubclass(..., PinakesError), which is true by construction and would have passed
for a subclass that omitted the keyword entirely. It reads the super().__init__ call out of the
parsed source now. Worth recording because it is the third time in this repo a safety check has been
written in the shape that cannot fail; the tell each time was that it passed the moment it was
written.
What E4 inherits from this increment, beyond what the plan already names: the three bounds E2
prices against are enforced in the client — the question ceiling, the carried-memory ceiling
(CARRIED_MEMORY_CHAR_CEILING, added beside the token constant it derives from), and final_k
passages per answering call — and each refuses rather than trimming. E4 should refuse earlier
and more kindly, with a sentence about the command; what it must not do is assume the client will
quietly cope. Citations arrive as passage numbers, so E4 owns the mapping back to documents, and
E7's rule that a suggestion's endpoints must be documents this run retrieved is already a property
of the wire format rather than a check to add.
Second review pass — the knob nobody would have passed, and a version nothing checked. Two more, both found by reading the module as a stranger would rather than as its author:
max_tokenswas a parameter on both request builders, defaulting to the estimator's constant. Harmless as written and a hole by construction: output bills at five times the input rate and is two thirds of a round's whole price, so a caller passing a larger ceiling is billed for output no reservation covered — the same hole the question, memory and passage bounds had just been closed for, sitting one line above them. It is gone;_requestalways sendsMAX_TOKENS. Unlike the extractor, this client never re-asks a truncation at a raised ceiling, so nothing wanted it.PROMPT_VERSIONandSCHEMA_VERSIONwere decoration. They existed for E5's transcript and E6's measurement, and nothing read them — which is exactly T1's failure with a template version:pnk doctorcompared a version against the installed one for ten releases while the files that version denoted changed underneath it, and every KB recorded a reference that matched and meant something different. So the prompts and both schemas are now hashed and pinned to their version, and a reworded prompt fails the suite until the digest and the version move with it. E6's numbers are about a specific wording; without this, the record of which wording would be a guess.
The pass also promoted DeepCallFailedError's three kinds to constants, for the reason E1 gives for
carrying its escalation value rather than re-deriving it: E4 has to tell a refusal from a truncation
in order to label a run, and a caller matching a string literal puts the vocabulary in two places.
And a note on the mutation testing itself. The first attempt at mutating max_tokens back into
_request reported "54 passed" and I nearly recorded it as a test gap — the edit had silently
matched nothing, because ruff format had collapsed the signature onto one line after the change.
A str.replace that matches nothing returns the string unchanged and tells no one, which is the
defect conftest._rewrite exists to prevent, reappearing in a throwaway script. Every mutation in
this increment now asserts its target matched exactly once before running the suite; the retried
mutation failed the right test immediately.
Third review pass — two findings, both about what a reader would look for and not find. The
shared-rule tests had landed inside tests/test_deep_client.py, so src/pinakes/paid.py — a module
both paid entry points depend on — had no test file bearing its name, and the nearest match,
tests/test_paid_path.py, is about something else entirely (which modules may import a client, not
what they must then do). Split into tests/test_paid.py, with each file's docstring saying which
question it answers, and VERIFICATION gains a section of its own for the rules rather than filing
them under the client that happens to use them.
The second: AnthropicTransport.max_retries was a property nothing asserted. build_client_kwargs()
== {"max_retries": 0} is tested, and says nothing about this transport passing it — the same gap
in a smaller frame as the classifier's. Constructing the client is offline, so the test is not
paid-marked; it skips with a reason where [claude] is absent.
Fourth review pass — the sentence this increment falsified was in the file it edited.
extract/claude.py's first line read "the only module in src/ permitted to import anthropic",
and AnthropicTransport's docstring said "the only place anthropic is constructed". Both were
true when written, both became false in the same commit that added the second entry to the
allowlist, and neither is reachable by grepping for what changed — the words deep, client and
allowlist do not appear in either sentence. Found by grepping for the claim shape ("only
module", "the only place") rather than for the diff, which is the same move that turned up
docs/ROADMAP.md:139 at E2's handover: a stale pointer has no wrong text to search for, only a
claim that is no longer true.
A Ctrl-C voided a call that may have billed — in both paid clients, for as long as either existed¶
Found by working an exit criterion nobody had tested. E4's plan asks that "interrupting
mid-loop leaves a reservation pnk budget reports as unknown outcome, never a lost record".
Nothing asserted it, so it was probed with a transport that raises KeyboardInterrupt, and the
ledger came back voided — EUR 0 recorded for a request that had already been sent.
Why it was invisible. Every deliberate branch was right. billed_call classifies a timeout as
billable-unknown, voids a 429 that never billed, and — in the deep client — catches Exception for
anything unclassified and leaves it unresolved. A KeyboardInterrupt is not an Exception, so it
fell past all of it into ledger.paid_call's finally, whose job is to close an unfinished call
and whose default is to void. Every layer behaved as written. The extractor was worse and had
been since I7b: no catch-all at all, so an ordinary defect voided too.
Three things worth keeping:
- The likely interrupt is the one nobody models. A paid run is slow, visible and cancellable; Ctrl-C during one is the normal way it ends when a user changes their mind. It was the only exit path with no test.
- A safe default one layer down is not a safe default.
close_unfinishedvoids because most unclosed calls never billed. That is correct there and wrong here, and the caller is the only place that knows which.except BaseExceptionis what says so. - The sibling had it too, and fixing one would have been the defect surviving. One invariant,
two call sites, two identical clauses — so both moved in the same change, and both tests raise a
BaseExceptionrather than aRuntimeError, because a narrower one passes against broken code.
The same GUIDE block quoted a retired sentence twice — so the second time it became a gate¶
E1 rewrote pnk search's escalation notice and left docs/GUIDE.md displaying the sentence it had
just replaced. Its retrospective recorded that nothing in the repo could have caught it. E4
rewrote the same sentence and left the same block stale again, caught only because someone grepped
for the old wording before shipping.
Why every existing check is blind to it. The prose is well-formed. Every link resolves.
mkdocs build --strict is green. tests/test_verification.py checks that named tests exist, not
that quoted output is current. A fenced block showing a previous build's output is correct
Markdown describing a program that no longer exists, and nothing in this repo reads it as anything
else.
The checkable half is the negative one. Diffing a fenced block against real output would need
the command, its models and a corpus. "Every printed constant must appear in the docs" is simply
false — most should not. But a sentence this build can no longer print must appear nowhere, and
that is a grep. tests/test_docs_quote_the_shipped_sentences.py holds the retirement list, one row
per retired sentence with what replaced it; retiring a sentence is a deliberate act, so adding the
row is part of it.
Two things that make the gate honest rather than decorative. It searches src/ as well as
docs/, because a retired sentence surviving in a docstring is the same defect one layer in — and
this project's docstrings are where its reasoning lives. And it was run against the pre-E4 tree,
where all four rows fail: a gate never observed failing is a gate nobody has tested.
E5 — The run transcript (20260812 05:11)¶
MEDIUM — a mutation restored a file and silently took a real edit with it. The mutation pass
works by editing a source file, running the suite, then git checkout <file>. Between the first
commit and mutation 5 there was exactly one source edit — a comment in cli.py correcting a claim
about clear_cache_paid — and git checkout src/pinakes/cli.py reverted it along with the
mutation. Nothing failed: the tests pass either way, ./check.sh is green, and the reverted text
was a comment, so no gate could see it. It was found by reading the increment's own diff in the
adversarial pass and noticing the comment said what the earlier draft said.
The lesson is narrow and mechanical: git checkout restores to the last commit, not to the state
before the mutation. Either commit before mutating, or restore with git stash/a copy. This
increment did the former for the code and got caught by the one edit that landed in between.
And it happened a second time, two passes later — git checkout tests/free_path_run.py, run to
undo a deliberate deletion that was proving a gate row discriminates, reverted the uncommitted
addition of that same call. Knowing the trap was not enough to avoid it; the only thing that caught
it the second time was checking for it immediately afterwards, because it was now expected. Write
the check, not the resolution: after any git checkout <file> in a mutation loop, grep for the
thing that was supposed to survive.
MEDIUM — a test that asserted the wrong half of its own claim. "The temp file is .tmp, not
.json, so a killed write leaves nothing the readers count" was tested by planting a
.tmp-abcdef.tmp file and checking the glob ignored it. That proves the glob ignores .tmp files.
It says nothing about what the writer names its temporaries, and it kept passing when the suffix was
mutated to .json. It now spies on os.replace, whose source argument is the file a kill one
instruction earlier would have left behind, and asserts on that name.
Generalisable: when a test plants the input it then checks, ask which half of the claim it actually reaches. The planted value came from the test's understanding of the code rather than from the code, so the two could disagree without the test noticing — which is the whole failure mode the mutation pass exists to find, and it found it.
LOW — the confirm-then-re-call path had never been tested, on either store.
sys.stdin.isatty() is False under pytest, so every --clear-cache test since I4 took the
--yes route straight past the prompt, the y/n branch and the second sync() call. Three tests
now walk it. Worth recording because the gap was invisible in the ordinary way: the flag had
coverage, the interactive flag did not, and no coverage number distinguishes them.
A decision worth writing down: --clear-cache=transcripts names a store, and the two values
before it name authorisations. --clear-cache and --clear-cache=paid both clear the whole
extraction cache and differ only in what they permit — a documented distinction, with a comment in
cli.py explaining why the bare form is not called =free. Layering transcripts onto that axis
would have meant --clear-cache=transcripts also emptying the extraction cache, which destroys more
than the flag names. Mixing the two axes in one flag is a real cost, and it was paid deliberately
rather than by accident: D-26 asked for a --clear-cache target, and a target is what it is.
What E6 inherits. The transcript is the record the measurement run reports out of: it carries
call_ids, the estimate and the reconciled spend per run, so the over-reservation factor E6 must
publish can be computed from the files a measured run leaves behind rather than from a spreadsheet
kept beside it. transcript.call_ids() plus sync.ledger_spend() is the join, already written for
the confirmation prompt.
E6 — A seam the tests never crossed (20260812 06:41)¶
CRITICAL — pnk ask --deep has never worked against the live API, and 0.22.0 through 0.25.0 all
ship it. The first real --deep call E6 made returned a 400 before it billed anything:
output_config.format.schema: For 'integer' type, properties maximum, minimum are not supported
deep/client.py: answer_schema emits {"type": "integer", "minimum": 1, "maximum": passages} on
every answer call, and structured outputs does not accept numerical constraints. A second probe
found subproblems_schema's maxItems is refused the same way, so the decompose call was broken
too — fixing the answer call alone would not have produced a working loop.
The lesson is about the seam, not the schema. E3 introduced Transport so the whole loop could
be driven from recorded fixtures with anthropic absent, and E4 and E5 were both tested that way,
green throughout. That is a good seam and it bought real things. What it also did was guarantee that
no test in the suite has ever sent a schema to the API — the one field the API validates and the
fixtures cannot. A fixture asserts what we believe a response looks like; it cannot assert that the
request was acceptable. Every layer above the seam was correct, which is exactly why nothing went
red: the defect lived in the only inch of the path the seam removes.
The generalisation: a seam introduced for testability defines a region the tests cannot reach, and that region needs its own gate. For this one the gate is cheap and fixture-free — assert the two schema builders emit no keyword on structured outputs' documented unsupported list. It would have failed at E4, on a branch that was green.
Three things behaved exactly as designed and are worth recording, because a bad failure is where
you find out. The accountant reserved, refused and voided: the failed call billed €0.0000, so a
run of 400s cannot consume a budget. The error surfaced the API's own sentence rather than a
paraphrase, which is what made the cause obvious in one read. And the blast radius was genuinely
bounded to the one path — pnk search, pnk ask without --deep, and the paid extractor were all
untouched, because none of them shares this client.
Running the instrument found two bugs in the instrument. tools/deep_reservation.py count
filtered its token-count payload to model/system/messages, dropping output_config — so it
measured PROMPT_TOKENS with the schema excluded, a fraction of the real figure. And
QUESTION_TOKENS differenced an arbitrary 200-word probe, so it returned ~200 by construction: it
measured the probe, not the question ceiling. Both would have published a wrong constant with no
symptom. A measurement probe is code and earns the same adversarial pass as anything else — two
of five constants were measured wrong on the first run, and reading the script is what found
neither; running it is.
E6 — The measurement run (20260821 07:53)¶
HIGH — the instrument that publishes the numbers had no tests, and its --json had never
run. vars() on a slots=True dataclass raises TypeError, so tools/deep_reservation.py
count --json and report --json both failed on their first row, in every version that ever
existed. It survived because every measurement session read the printed table instead. The
general shape is worth keeping: a tool written to be run by hand has exactly one exercised path
— the one the author happened to type — and the flag nobody typed is not a lesser-tested path,
it is an untested one. The fix is cheap; noticing was the whole cost, and what noticed was sitting
down to write the tests rather than any run of the tool.
HIGH — the published factor was not reproducible, and nothing said so. The handover recorded
19.0× on synthesis and 16.5× on the loop. Those came from measurement KBs under /tmp, which the
operating system reaped after nine days, taking every transcript and every ledger row with them.
The money was spent and the numbers were real, but by the time anyone re-ran report the evidence
for them was gone — and report cheerfully printed a different factor over the surviving
records without any indication that it was answering a smaller question. A measurement whose
substrate lives in /tmp has a shelf life, and the number outlived it. What made this
recoverable rather than merely lost is that the runbook's rebuild step is free: the KBs come from
tests/demo-kb and tests/partner-kb, so the run could simply be done again.
HIGH — the runbook's own step (c) measures the wrong branch. It names three no-answer
questions to exercise the loop. On the calibrated KB one of them — "Which software does the
catalogue run on?" — scores medium, which takes the cheap branch: running the runbook as
written buys a synthesis call and records it as a loop measurement. The free pre-flight the same
document prescribes is what caught it, one paragraph after the document tells you to run it. Step
(b) already warns that "a 2 means the branch was mis-selected and the run measured the wrong
thing"; the inverse case had no such check, and the questions were never re-verified after the
thresholds were fitted.
HIGH — and the reason step (c) chose those questions is itself false. It argues that
no-answer questions are the right instrument because "nothing in the corpus answers them, so the
sufficiency gate cannot stop the run early and it goes to the round cap — which is the worst case
the reservation was sized for, and therefore the only case that measures it." Measured: both
decomposition runs stopped at sufficiency, after 2 rounds and after 1 round of 3. A
sufficiency gate reading a calibrated signal is perfectly willing to conclude that enough has been
established about a question the corpus cannot answer. So on a calibrated KB the loop's worst case
is not reachable by choosing a hard question, and the branch that actually reaches the round cap
is unknown, on a KB with no thresholds at all — the branch the runbook does not mention.
MEDIUM — the more calibrated the KB, the more over-reserved its loop is. The three branches
came out 29.75× (synthesis), 50.92× (calibrated loop) and 22.35× (uncalibrated loop). The
ordering is not noise and it is not a defect: a reservation must cover max_rounds, and
calibration is exactly what lets a run stop before it gets there. The uncalibrated branch is the
least over-reserved because it has no early stop and spends the rounds it reserved. Reporting a
single blended figure would have hidden this entirely, which is the argument D-28 made before any
of it was measured.
MEDIUM — MAX_TOKENS carries most of the over-reservation, and is the constant least safe to
lower. 8,000 reserved against a widest-observed 660 across 22 reconciled calls (mean 241) —
12.12×, against 1.50× to 8.93× for the five input constants. It dominates because output bills at
five times input and is two thirds of a round's price under the shipped defaults — and four
fifths at the narrower geometry these runs actually used. It is also the only one of the six that is
a truncation rather than a bill: an input ceiling set too low over-reserves, while an output
ceiling set too low cuts an answer off mid-sentence. The temptation to lower the one constant that
would visibly improve the headline figure is therefore precisely inverted from where it is safe to
do so, which is worth stating plainly next to a 12× ratio.
LOW — the [budget] block the runbook tells you to append is now two-thirds the shipped
default. It prescribes per_operation_eur = 2.00 and daily_eur = 6.00; D-30 raised the
defaults to exactly those values in 0.24.0. Both measurement KBs ran the whole plan with no
[budget] section at all. Harmless, but it is a hand-editing step the document still asks for and
no longer needs — and the two keys that do still differ (confirm_above_eur, monthly_eur) are
the two it does not explain.
LOW — the refusal path had never been run, and works exactly as specified. Step (d),
untouched through two measurement sessions. With per_operation_eur = 1.00 against the loop
branch's €1.38 estimate: refused before the first call, exit 1, no ledger row (22 lines before
and after) and no transcript (7 files before and after), with a message naming the cap, the
headroom, the branch, the call and round count, the complete manifest edit that would admit the
run, and the two cheaper routes before raising a cap. D-23 and E5's "a run that never returned
writes none" both hold.
A prose tool pointed at a comment run containing load-bearing whitespace (20260821 22:48)¶
Found by the E6-close adversarial pass, after ./check.sh was green — twice. While annotating
estimate.py's constants, textwrap was used to reflow the affected comment runs to fix
line-length errors, and it flattened an indented \-continued shell command into
... \ tests/demo-kb/docs on one line — a literal backslash argument for anyone who pastes it.
Caught before the tag, fixed in 4d5debf.
The class, and why no gate can see it: the result is legal, correctly-lengthed comment text, so
ruff accepts it; it is a comment, so pyright never reads it; and a diff review reads the new
prose rather than the old command. No assertion anywhere could have failed — which makes it a
clean example of what the mutation battery cannot reach, now noted in
docs/BUILDING.md § 4. The rule: a prose tool's output over text containing
load-bearing whitespace is re-read as the thing it is — a command, a table, an indent — never as
prose.
The sibling case, from the same day's release sweeps: a script that navigates Markdown by a
heading prefix steps over every shallower heading. 0.25.3's sweep inserted its ROADMAP section
with a startswith("## ") scan for the next section — and # Part 5 · What is not built starts
with "# ", so the scan stepped straight over the divider and the Part heading and placed a
finished release section inside the unbuilt-work part. release_order_gate could not see it: it
checks order among release sections, never which part they sit in. Diagnosed by the sweep's own
author from the script, not guessed; the other three insertions from that sweep were each anchored
immediately after a located line and are correctly placed — the one that navigated forward to
find its slot is the one that crossed a boundary it could not represent. Moved in 0.25.4. The rule
both halves of this entry share: a prose-shaped tool applied to a file whose structure carries
meaning it does not model needs its output re-read against that structure — heading level,
load-bearing whitespace, a divider — never against the text alone.
The same pass also found a shorthand ("output is two thirds of a round's price") that is correct beside the shipped defaults and wrong at the measurement KB's geometry (four fifths there); it was qualified at its five new uses only, deliberately — the pre-existing uses sit beside default-geometry arithmetic, where the shorthand is exact, and a blanket qualifier would cost every reader to protect none.
E7 — Printed suggestions, and the mutant that proved the battery blind (20260822 01:24)¶
The increment that closes the deep release. A --deep run ends by printing the links[]
entries its own citations propose. Small — one module, one CLI seam — and it turned up four defects
the tests could not see, three of them found by mutating rather than by reading.
HIGH — the control mutant survived, and it was the first one run. docs/BUILDING.md § 4 says
to kill a known-catchable mutant first, because a run with no kills is a broken harness rather than
a clean bill. This run had seven kills and lost the control: REL = "co-cited" → "related" left
all 71 tests green. Every assertion about the relation imported REL and compared it with itself,
so the constant and the expectation moved together. The shipped value of the thing a user pastes was
pinned by nothing.
The generalisation is worth more than the fix: a constant imported into both sides of an assertion
is not tested, it is restated. test_cli_ask.py's FINGERPRINT comment says exactly this one
file away — "a test that derives the expected value from the same object it checks would still pass
if fingerprint() started returning the empty string" — and this suite was written without
noticing it applied. One literal test now names ("co-cited", "deep"). Half of it was already
caught by accident: rel = "" went red, because sidecar._links refuses an entry without one. Only
the plausible wrong value survived, which is the direction that matters.
HIGH — a test that certified containment was satisfied by absence. The escaping-path test cited
../outside.md, a path with nothing at the end of it, so the read failed because the file was
missing and the assertion passed with no containment check at all. The mutation pass is what
separated them: the bypass mutant killed the neighbouring deleted-document test and left this one
green, on the same code. It now cites a real document in a second KB next door, with a sidecar
carrying the id the citation claims — so only containment can refuse it, and what it prevents is
nameable: a fragment carrying pnk://<this KB>/<that KB's document>.
MEDIUM — three direction tests could not observe direction. ULIDs are monotonic, and the fixture
built its documents alpha, beta, gamma, so the ids ascended in the same order as the paths. Every
assertion about which sidecar an entry lands in was green whether the code ordered by path or by
id. Minting the fixture backwards made the two orders disagree — and immediately paid: the entry
order inside a sidecar was by URI, which is mint order, which is arbitrary to a reader. A
fixture whose two orderings agree cannot tell them apart, and the agreement is usually accidental.
MEDIUM — a newline in a filename would have broken the fragment, silently. The module builds
YAML as text, which is defensible: every value in it is a ULID URI or one of two constants, and a
test pins that against sidecar.needs_quoting. But the document paths go into YAML comments,
and a POSIX filename may contain a newline — one ends the comment and turns the rest of the path
into a node. needs_quoting cannot see it, because it answers about a scalar and a comment is not
one. The lesson is the sibling of 0.25.3's reflowed shell command: a value that is safe as a
scalar is not thereby safe as a comment, and the check that certifies the first says nothing about
the second.
Two more, from reading the diff as a stranger. propose is public and re-checks every endpoint
it is handed, but would have proposed a document linked to itself — which pnk link refuses
outright. And resolution was quadratic in disk reads: a block citing n documents makes n(n-1)/2
pairs, each resolving both endpoints, so every sidecar was read and YAML-parsed n-1 times, with
[retrieval] final_k having no ceiling and the money already spent.
The design decision worth keeping: observing and proposing are two functions, and the split exists
so a test can exist. § 5's rule is that a suggestion's endpoints must be documents this run
retrieved. Enforced inside one function, the rule is unfalsifiable — every candidate comes from the
same expression that validates it, so a working guard and a missing one produce identical output on
every input a test could construct. co_citations observes; propose re-checks what it is handed;
the refusal test calls propose with a pair no run would produce. A guard whose input is built by
its own validator is not a guard, and its test is a tautology in test clothing.
What the battery still cannot reach. The prompt-injection test — a retrieved passage instructing
the model to add a link, obeyed in prose, producing nothing — asserts the absence of a behaviour.
No mutation makes it fail, because the code that would fail it was never written: nothing in the
module reads AnswerBlock.text. It is a real test of a real property and it is not mutation-backed,
which is the same class docs/BUILDING.md § 4 names as a defect with no assertion anywhere, seen
from the other side.
And an old row that had been false for four releases. docs/DESIGN.md §9 still bounded --deep
with "no orchestration the free path doesn't have" — written before the loop existed, and
contradicted by the loop the moment it shipped. docs/graph/PINAKES_APPROACH.md § 6 had asked for
that exact row to be amended in the increment that shipped the design, and named the replacement
bound. E4 shipped and the row did not move. Found here by auditing the neighbourhood rather than the
diff — the increment that closes a release is the last cheap chance to fix what the release made
false, because after it nobody is reading those rows for a while.
Ten of ten mutants killed after the fixes, control included, with the containment mutant now caught by the containment test rather than by its neighbour.
The mutation harness — turning the last unguarded step into a tool (20260822 06:14)¶
plans/20260821_0745-mutation-harness.md, in one increment. The precedent is tools/land.py: when
prose has failed repeatedly against a class of mistake that fails silently, the rule stops being a
rule and becomes a tool.
HIGH — a skipped test and a passing test are the same exit code, and this repository skips
constantly. Not in the plan; found by measuring pytest before writing any code. A selector whose
tests all skip exits 0, which is byte for byte the SURVIVED signal, and Pinakes skips on a missing
extra by design (pdf, paid, model, the three-leg CI matrix). A battery aimed at one of those
on a [light] checkout would have reported every mutant in it unpinned — a confident, wrong claim
about exactly the assertions a mutation pass exists to check. The mirror case is worse and equally
invisible: a selector that is already red reports KILLED for every mutant aimed at it. One
pre-flight run per selector closes both, before any file is written.
HIGH — SIGTERM skips finally, so a try/finally restore is not a restore. Measured, 1 of 1.
SIGINT already raises KeyboardInterrupt, which is why the hazard is invisible to anyone who only
tests with Ctrl-C — and why the first version handled only SIGTERM until the harness's own battery
showed that dropping SIGHUP and SIGQUIT was a one-word edit no test could see. SIGKILL cannot
be handled at all, which is the real reason the plan's refuse if the target differs from HEAD
step is not a formality: after a hard kill the only recovery is git checkout <file>, and the
refusal is what makes that recovery correct rather than the seventh instance of the trap.
MEDIUM — the T3 trap reproduces 6 times out of 6, and its test must not race the clock. A
same-length mutant (min(value, MAX) → max(value, MAX)) written in the same wall-clock second as
the previous compile passes every test, because CPython validates a .pyc on
(mtime-to-the-second, size). Reproducing it first is what made the test honest: the obvious test
— assert the same-length mutant is KILLED end to end — goes green on a slow machine with the
invalidation deleted, because the second boundary is crossed anyway. Two tests replace it. One
asserts no bytecode cache exists during a mutation, which has no clock in it at all; the other
forges the stale condition with os.utime and watches the mutant vanish, so the clearing has a
control rather than being ceremony.
MEDIUM — pytest's <error> tag covers two opposite events, and conflating them threw away real
kills. A collection error is the invalid mutant: nothing ran, no assertion was tested. A setup
or teardown error is a real node the mutant broke on the way in or out — and in this repository
fixtures build indexes, manifests and KBs out of src/, so fixture-mediated detection is the common
shape rather than a corner. Treating both as ERRORED, and testing that before failures, reported a
mutant that tripped a fixture and failed a plain assertion as "the mutant did not run", tallied
0 killed. The two are now told apart structurally — a collection failure carries no line
attribute — rather than by matching pytest's message text.
The correction that mattered more than the finding. The reviewer who filed that one also proposed the fix: route setup and teardown errors into KILLED. An independent skeptic, asked only to refute the finding, confirmed the defect and rejected the remedy — routing them into KILLED would manufacture the false green the tool exists to prevent, because no assertion fired and nobody may write "pinned by test X" for a fixture noticing something. The shipped fix keeps the conservative direction and repairs only the sentence. An adversarial pass is worth more when the verifier is allowed to disagree with the finder's remedy as well as with the finding.
The shape that recurred three times: a guard the CLI cannot reach. Pointing the tool at itself
found, in three separate rounds, four clauses that no battery-driven test could kill — the second
anchor check inside applied(), classify's timed_out and setup_errors branches, and the
report's only-a-KILLED-row-may-name-a-killer guard. Each is redundant against the code as it
stands today and each guards a state one line of drift away. Every one surfaced as a SURVIVED
row, which is the row the tool exists to print; none would have been visible to a reviewer reading
the diff. They are kept, and each gained a direct test on the constructed state rather than being
deleted as dead code — redundancy nothing tests is indistinguishable from redundancy that has
quietly stopped working.
Twice, the battery's own selectors were the thing that was stale. Both times a guard read SURVIVED because the battery still named the test that existed before the fix, not the one written for it. The lesson is narrow and mechanical, and it is about the battery rather than the code: a battery is source that goes stale like any other, and a SURVIVED row is a claim about a pair — the mutant and the selector — either half of which can be wrong. The third time, the anchor pre-flight caught it before a single mutation ran, which is the whole argument for hoisting that check.
What the tool still cannot see. A survivor is a claim that wants checking by hand — the record holds two increments where a mutant that did not reproduce the real prior logic was briefly taken for a result, and the E7 session's own control mutant survived because every assertion imported the constant it compared against. A mutation result implicating a file the mutant never touched is a result about the harness, not the code, and nothing here detects that.
The exit criterion, met the only way it could be. The tool was run against itself: 25 mutants,
each disarming one of its own guards, 25 killed, each by the test named beside it. Before that,
two mutants of src/pinakes/graph/traverse.py's caps, both killed, tree clean afterwards — because
a tool that has only ever been run on its own fixtures has not been run.
A gate cited by a procedure it cannot read (20260822 06:35)¶
docs/RELEASING.md's sweep table names five places a release stales. One is STATUS's Published on
PyPI prose. Its "where the new entry goes" row answers: python3 tools/release_order_gate.py
decides it. No pattern in that gate matched the list. So the procedure delegated a placement
decision to a check that could not read the document, and the green line 5 sequences in release
order was read as covering a list it had never opened.
It drifted, as delegated-to-nobody things do: 0.25.1 → 0.25.3 → 0.25.2 → 0.25.4, wrong on SemVer
and on verification time, surviving every green run from 20260821 to 20260822.
The generalisable shape is not "a missing pattern". It is a citation nobody checked. A document naming a tool as the authority for something is a claim about that tool's coverage, and it is exactly the kind of claim that is written once and never re-read. Grep the other direction occasionally: for each gate, what do the documents say it covers, and does it?
Three things that fell out of building it, each worth more than the fix:
- A gate reads an order; it cannot see a count. Landed the same day, from the other side: a
concurrent session appended "thirty-seven" to a sentence that still said "thirty-six", one line
apart, through a green
check.sh, a greenmake docsand a greenrelease_order_gate. Caught by grepping the neighbourhood, not by reading the diff — the wrong half was context, so it never appeared in the diff at all. Counts stay a documentation rule for that reason. - "Tolerate it" needs a direction, or it is a hole. This list is legitimately short between a release landing and its verification, because a claim about the index is held back until it is verified from the index. Exempting it from agreement is right; exempting it from direction is not. It may lag every other sequence and may never lead one — a paragraph about a release the CHANGELOG has never heard of is a claim nothing else records. The first draft had the exemption and no direction, and nothing could have told the two apart.
- Say what the exemption costs, in the gate. Because a missing newest entry is legal, an entry written in a shape the pattern does not match is indistinguishable from one not yet written — silently unchecked. The floor catches wholesale rot; it does not catch one mis-shaped newest entry. Undocumented limits get trusted past.
And one environmental trap, found before it could produce a false green: this repo has no
.python-version, so uv gave a fresh worktree CPython 3.14.7 while the primary checkout and CI
run 3.13. A green ./check.sh in a new worktree is therefore not evidence about the interpreter
anything else uses. Pinned by hand here; the root fix is a proposal, not this increment's.
Verify the remedy, not only the finding (20260822 07:25)¶
docs/BUILDING.md § 5 already says a fix applied under review inherits the review's confidence and
none of its scrutiny. This is its sibling, and it is about the review pass rather than the commit
after it: a correct finding arrives with a proposed remedy attached, and the remedy is the half
nobody re-checks. The finding survives scrutiny; the remedy rides in behind it.
Two instances the same night, in two sessions working concurrently, each caught only because the second pass argued with the fix rather than stopping at the defect:
- A remedy that would have manufactured a false green. A classify bug was real and confirmed. Its proposed fix would have made the check report success on input it had never examined — a repair that removed the symptom by removing the observation.
- A remedy that was right and unbounded. STATUS's Published on PyPI list is legitimately short between a release landing and its verification, because a claim about the index is held back until it is verified from the index. Exempting that sequence from the release-order gate's agreement check was correct. Exempting it without a direction was a hole with a docstring: no input could distinguish a working exemption from a missing one. It became may lag, never lead — the list may fall behind every other sequence and may never name a release the CHANGELOG has not heard of.
A review culture that re-checks only findings ships both of these at full confidence. Both were defensible, both were proposed by the agent that had just been right about something, and that is exactly what makes a remedy hard to doubt.
The cheap test: state what input would look different if the remedy were absent. If none exists, the remedy is unfalsifiable and the test written for it will be a tautology — the same failure E7 recorded, where a guard whose input is built by its own validator cannot be shown to guard anything.
The cheap structural version, if only one thing survives from this: when an adversarial reviewer is asked to refute a finding, ask it to refute the proposed fix as a separate verdict. Both instances above were caught that way and neither was caught any other way — one by accident, when a skeptic exceeded its brief, and one by design, when the second pass asked tolerant in which direction?. It costs one more question per finding. The mechanism and the first instance came from the concurrent session; this fragment shipped without them and had to be amended, which is the argument in miniature.
And it worked because reviewing was somebody's assigned job. That amendment was found by a session whose remaining task was to look at work other people had finished — development split to one agent, planning and review to another. A review that happens as a courtesy after the work is done is the one that gets skipped when the work runs long; the catch here depended on it being nobody's optional extra.
A coda from the concurrent session, worth keeping beside this. The night's most serious defect —
pnk serve raising ModuleNotFoundError on every fresh install of every published version, from a
lower-bound-only dependency pin that 37 --frozen CI invocations never resolve — was not found by a
test, a gate or a review. It was found by someone asking what should we build next and installing
the product to answer it. docs/BUILDING.md warns that a test seam defines a region no test reaches;
here the region no test reached was the product itself. Reproduced independently in both
sessions before it was written down.
Capping mcp, and the leg that had to be run against the defect (20260822 08:20)¶
HIGH — a CI leg written to catch a defect is a claim until it is run against the defect, and the
mutation battery cannot reach this one. No mutant of any tracked file makes uv resolve mcp
2.0.0: the thing under test is a dependency resolve, and tools/mutate.py mutates bytes in the
tree. So the leg was verified the only way left — git archive HEAD | tar -x into /tmp, the cap
deleted from the copy, uv build, and both new steps pointed at the resulting wheel:
wheel-import: 1 module(s) did not import against the resolved dependency set:
pinakes.serve: ModuleNotFoundError: No module named 'mcp.server.fastmcp'
and pnk serve exited 1 with no "serverInfo" anywhere in its output. (That block is the run's
real stdout, pasted. The first draft of this fragment paraphrased it into two module lines — in
the fragment whose whole thesis is that the artifact has to be run. Caught by review; a quoted
output that was retyped is a claim wearing evidence's clothes.)
Without that, "the leg would have caught it" is a statement about a check that has only ever
been run against a fixed tree — the shape docs/BUILDING.md already refuses for a
test ("pinned by test X is a claim about a failing test"). The generalisation: when the battery
cannot reach a guard, build the broken artifact by hand and point the guard at it. It cost about
four minutes.
HIGH — the review's own remedies were where the remaining defects were, exactly as verify the remedy, not only the finding predicted the night it landed. Two of the three worst findings in this increment were not in the first draft's logic but in the mechanisms written to guard it:
- An allowance keyed on the library forgave the module the gate exists for.
--allow-missing pypdfium2excused any module failing onpypdfium2— includingpinakes.serve, had anything on its import chain ever reached it. Demonstrated against a synthetic package:servedid not import and the gate printed a clean pass. An allowance now names the module and the library, and a--required module may never appear in one. - The "the gate can still fail" step was satisfied by an environment where the gate never ran.
It grepped the tool's generic failure headline — which the tool also printed when the package
itself was not importable, i.e. when
--withinstalled nothing (a mistyped extras syntax does exactly that; measured). The two branches now print different headlines and the step names the one failure the bare wheel genuinely has. The defect was inside the step written to prevent that class, and a test pinned the collision rather than catching it.
The fragment's cheap test applied to this increment's own thesis — what input would look different if the remedy were absent? — is the uncapped wheel above. It exists, and it is the answer to the question this increment turns on: without it, the cap is the only real change and the CI leg is decoration.
HIGH — and then the remedies for those had defects, in the same shape, found by a second review that was asked only to refute them. Three rounds, each finding less than the last, and the third still finding four things that mattered:
- The guard against a missing wheel failed open.
set -- dist/*.whl; test -f "$1" && test $# -eq 1— underset -e, a failing first command of an&&list does not abort, so an emptydist/fell through towheel="$PWD/dist/*.whl"and carried on. The two-wheel case aborted, which is why it looked right. It is anifnow. Two sessions found this independently within minutes of each other; it came in as a review suggestion and was implemented verbatim. make smokeexited 0 while printing a traceback.pnk serve … | grep -q '"serverInfo"'— grep matches, closes the pipe, the server dies onBrokenPipeError, and the pipeline's status is grep's. The target printed "answers an MCP handshake" over a crashed server, and a changelog entry claimed it ran the same checks as CI. Output to a file, then grep it twice.continue-on-error: truedefeats an assertion written againstif:. The test said a gate that can be switched off is not a gate and then checked one of the two ways to switch one off. On the step in front ofuv publish, where being wrong is a version number PyPI will not take back.- The source-tree refusal was scoped to the checkout the script lives in. This project mandates
a worktree per change, so another checkout's editable install is a path the gate has never
heard of — demonstrated with one checkout's interpreter and another's gate: a clean green pass
over
src/pinakes. A negative check has to enumerate every wrong answer; the positive one — the package must be inside asite-packagesordist-packagesdirectory — has one right answer, and that is the difference between the two shapes.
And a declaration test only pins the file the last edit touched. --min-modules 50 sat at
four call sites across three files; deleting it from any one of them survived every test, and the
tool then falls back to a floor of 20 against a package of 57 modules. The assertion now iterates
the invocations rather than grepping the tree, which is the same lesson as read the sequence, not
the neighbourhood in docs/RELEASING.md: a property of a set is not a property of
any member of it.
MEDIUM — a gate that reads a range of lines reads the prose inside the range. check.sh's
extras-not-core gate is awk '/^dependencies = \[/,/^\]/' pyproject.toml | grep -qiE
'pypdfium2|anthropic'. The comment added above mcp — explaining that anthropic was measured
and deliberately not capped — turned it red and reported anthropic as a core dependency. The gate
had been correct for a year only because nobody had written a comment in that block. Same class as
0.25.3's textwrap reflow of a \-continued shell command: a line-shaped tool applied to
structure it does not model. The parsed-side test never had the problem, because it reads the
requirement list rather than the lines.
MEDIUM — nothing resolves a test path written in a comment. check.sh and
tests/test_paid_path.py both pointed readers at
test_packaging.py::test_paid_and_pdf_clients_stay_out_of_core. That test has never existed under
either file's history; the real name is test_extractors_stay_extras. docs/VERIFICATION.md is
gated for precisely this reason — a table of test paths is prose until something executes it —
and a comment is prose that nothing gates at all. Both fixed; the class is open everywhere else.
MEDIUM — measure the sibling before capping it, and measure the surface you actually call.
Capping all three lower-bound-only requirements was the obvious response and would have been
wrong. Both siblings were measured: anthropic 1.0.0 and sentence-transformers 6.0.0 keep every
constructor parameter, response-model field (Message, Usage, MessageTokensCount) and method
signature (encode, predict, get_sentence_embedding_dimension, max_seq_length) that src/
touches. The first pass measured only the constructors, and review caught that. The gap mattered:
extract/claude.py consumes response.model_dump() as a dict, so a renamed Usage field would
compute a cost of 0 — the spending guard silently disabled, with no exception anywhere. A
constructor signature says nothing about that. The remedy for the class is testing the resolve,
not capping on reflex; a project that caps everywhere buys the same silence with a different
cause.
The battery is what turned four rounds of prose into four rounds of evidence, and the survivor count went the wrong way twice before it went the right way.
| Round | Mutants | Survived | Where |
|---|---|---|---|
| 1 — the increment | 11 | 0 | — |
| 2 — after review 1's fixes | 21 | 3 | tests written from the fix's own description |
| 3 — after review 2's fixes | 26 (reviewer's) → 33 (mine) | 12 → then 2 | the remedies' own surface |
| 4 — after review 3's fixes | 25 (reviewer's) → 40 (mine) | 19 → then 0 | the remedies' remedies |
Round one said 11/11 and meant it about eleven assertions, which is all a battery ever means. Every
later round found survivors in the surface the previous round's fixes had added — exit 1
changed to exit 0 in a step that then printed ::error:: and reported success; a continue-on-error
on the job under tests that guarded the steps; --min-modules 1 under a test that asserted the
flag was present; a leading - in a Makefile recipe, which is make's own way of ignoring an exit
status. A remedy is new code, and new code nothing has tried to break is a claim. The count that
matters is not 40/40, it is that three consecutive reviews each found something and the fourth
round's mutants were written by someone who had not written the fixes.
What the new legs still cannot see, named so nobody reads them as more. Import-time breaks
only, on the install states CI runs. A dependency that keeps its module layout and changes a
signature passes every step added here and fails on a user's machine. [st] — the default
backend — is resolved fresh by nothing, because it is ~2GB of torch. And ci.yml runs on push and
pull_request, so a major published on a quiet day is caught at the next push or at the tag, never
before: release.yml is where that matters, and it now carries both checks in front of
uv publish.
A gate must not derive a constant from the document it polices (20260822 17:52)¶
Membership's first draft derived each sequence's start version from the sequence itself — "the
oldest release it contains" — and justified it in the commit message: the starts are observable and
monotonic; they never move backwards, because releases are never deleted. Every clause of that is
true and the conclusion is still wrong. The gate does not observe releases. It observes the
document. Delete STATUS's 0.2.0 row and the derived start becomes 0.2.1: the sequence is still
sorted, still contiguous, still internally consistent, and the gate reports green on precisely the
deletion it was built to catch.
It is the same failure the gate's own docstring already refuses one paragraph earlier — the direction is declared per sequence, never inferred, because a badly scrambled file would otherwise elect its own answer. I read that sentence while editing the file and then wrote the inferred version of a different constant. A rule stated about one field does not defend the next field somebody adds. Four declared constants cost four lines and cannot be argued into being wrong.
The general shape, worth checking on any gate here: for each constant the gate uses, ask where it came from. If the answer is "the thing being checked", it is not a constant, it is an echo.
A status read is not a status gated on¶
CLAUDE.md names this trap in its piped costume: check | tail && git commit reports tail's
status, so a failing checker looks green. I hit the unpiped one and it is worth naming separately,
because the written rule did not cover it and I had read that rule the same day:
./check.sh > log 2>&1; echo "exit=$?" # status printed, and read, and correct
git add -A && git commit ... # runs regardless, on the next line
The status was captured, printed, and true. Nothing consumed it. A commit landed over a red tree — a lint error, caught later — and the log shows a green-looking sequence of steps. The pipe was never the mechanism; the mechanism is that the exit status must be what the next command reads, and a human reading it off the screen is not the next command. The fix is one wrapper:
./check.sh > log 2>&1; RC=$?
if [ $RC -ne 0 ]; then …report…; else …commit…; fi
And a third instance of verify the remedy, not only the finding¶
The exception mechanism for 0.11.0 was agreed with the planner as a loose match — find the
version anywhere in a ## heading. Implementing it meant first finding where that heading actually
is: docs/ROADMAP.md:1721, inside # Part 5, with the release table linking to it on purpose.
0.11.0 has no Part 4 section by design. The loose match would have taken that heading as its
release section and failed twice on a correct document — placement, because Part 5 declares no
range, and ordering, because it follows 0.27.2.
The finding was right: membership needs an exception mechanism. The remedy attached to it was wrong, and the risk that killed it had been named in the same message that proposed it — "matching a version anywhere widens what counts as a release section" — and read by both of us as hypothetical. It was not hypothetical; the widened set already had a legitimate member. A risk stated beside a remedy is not a risk that has been checked.
The mcp 2.x port — the port was four lines, the gate was not (20260822 18:23)¶
HIGH — A gate whose passing depends on a dependency's teardown timing is not a gate. The
handshake in both workflows wrote three JSON-RPC lines into pnk serve and closed stdin. Measured
on the same three lines, same KB, ten runs each:
initialize answered |
tools/list answered |
|
|---|---|---|
mcp 1.28.1 |
10/10 | 10/10 |
mcp 2.0.0 |
10/10 | 2/10 |
a real ClientSession, 2.0.0 |
8/8 | 8/8 |
1.x drained the queue before shutting down on EOF; 2.x does not. Nothing in the step named that dependency, and nothing could have: it is a property of a library's shutdown path, not of its API. The generalisation is worth more than the instance — if a check's passing depends on a race nobody in this repository wrote, it is not a check — and the remedy is not a longer sleep but removing the race's cause, which here meant driving the session with a client that holds the connection open until it has its answers.
The timing is the uncomfortable part. That handshake was written the same morning, in
0.27.2, to catch the mcp outage — and it was written against the behaviour of the version it
was about to lose. A gate built for a dependency major was itself a hostage to that major. git
log -S '"method":"initialize"' returns exactly two commits: the one that added it and the one that
removed it, four hours apart.
HIGH — The third copy is the one nobody looks at. Two workflows were fixed; make smoke — the
pre-tag check a maintainer actually runs — kept the dead shape and was red on every run against
a healthy wheel. Review found it, and found the second half too: test_make_smoke_exercises_the_wheel_rather_than_only_its_version
asserted '"method":"initialize"' in body and passed, certifying "drives a handshake" against a
recipe that no longer produced one. When a shape is replaced because it is wrong, grep the whole
tree for that shape before believing it is gone — and check what the tests pinning the old copies
now assert, because a test written against an implementation outlives the implementation.
HIGH — There is no independent source of a project's own version inside its own checkout. The
version test compared what the server advertised against importlib.metadata.version("pinakes"),
reaching for independence from pinakes.__version__. [tool.hatch.version] reads that same file,
so the metadata is the same constant copied into .dist-info at install time — and uv run
does not refresh it when the constant changes. Bumping __version__ to cut the release diverged
them at once and reddened two tests, with a message blaming the serverInfo defect the increment
had just fixed. A landmine the release commit would have stepped on twenty minutes later.
The lesson is not "use the other source". It is that a longer route to the same origin is not a second opinion, and saying so in the docstring is worth more than the appearance of rigour. The genuine cross-check exists, but only in CI: the expected value comes from the built wheel's filename and the advertised one from a separately installed copy of it.
MEDIUM — An error message is part of the assertion surface. assert EXPECTED in result.stderr
looked like it checked that a failure names what was expected. It was satisfied by the gate's own
sentence, "Until 0.27.2 this field carried the mcp library's version" — because EXPECTED was
0.27.2. The assertion held with the fix deleted. A version literal in a diagnostic is a value a
test may accidentally be reading; the message now names no version.
MEDIUM — The negative form of a rule is not the rule. Two exit-status pins asserted the absence
of one literal string, out=$(timeout — the shape the step used to have. | tee, || true, if
…; then and a backticked capture all stayed green, and | tee is the likely edit, since the
shape it replaced existed precisely to print the gate's output. The positive form —
this invocation is a simple command under timeout — is one assertion instead of an
ever-growing list of forbidden ones. Generally: when a rule says "the status must reach set
-e", assert the shape that makes it true, never the shapes that made it false last time.
MEDIUM — step["run"] is a YAML literal block, comments included. release.yml's pins were
bare substring searches over it, so commenting out the commands left every assertion satisfied by
the prose above them. Its ci.yml twin had required command lines since 0.27.2 for exactly this
reason and the release side never got it — a fix applied to one of two twins is half a fix, and
the release path is the half where being wrong is irreversible.
MEDIUM — The only untested branch was the one the gate exists for. A server dead on import —
the 0.27.2 outage itself — reached the gate's generic session-failure path, and no test drove it.
Also untested: the timeout ceiling and serverInfo.name. All three now have one, and --timeout
became a real flag so the timeout test costs a second rather than thirty. Ask which branch the
tool was built for, and check that one has a test before counting the others.
MEDIUM — A test can pin a message and leave the behaviour free. The timeout test asserted
"no complete session in 1s" and nothing about the clock. The mutation battery wired timeout= to
a constant while the message still read the flag, and reported ERRORED rather than a kill — the
harness refusing to call a run that never finished either a kill or a survivor, which is exactly
what tools/mutate.py was built to do. The test now asserts elapsed time as well, with a loose
bound: what must be true is seconds rather than the job.
LOW, and a pattern rather than an item — four false statements, all in prose written to explain
why the code was careful. SESSION_TIMEOUT_SECONDS was commented "Ten" beside a value of 30; a
docstring said comparing __version__ with itself "would hold with the version= argument
deleted", which is simply untrue; tools/wheel_import_gate.py still said uv.lock pins 1.28.1;
and a changelog fragment called the flaky handshake "a coin flip that landed the same way for a
year" when git log -S puts it at four hours old in a repository 28 days old. Explanatory prose
is where false claims accumulate, because it is written once, at the moment of most confidence, and
never re-read against the code it explains.
LOW — Two run-the-real-thing checks that no unit test could replace. The three CI legs were run
verbatim against a built wheel resolved fresh onto mcp 2.0.0, which is the only way to learn that
uv run --isolated --no-project --with <wheel> puts pnk on PATH for a child process the gate
spawns. And the gate's timeout was driven with a shim that spawns and stays silent: it exits 1 after
32s and leaves no orphan behind it.
Process — the primary checkout's tree can change under a running gate. A peer session ran
tools/land.py while ./check.sh was running in that same directory, and three tests failed with
"matched 0 # Part heading(s)" — loud, plausible, and pointed at the file the peer had just
touched. Nothing was wrong; a re-run at the new sha was green. Run gates in your own worktree.
The primary checkout is where landings happen and its tree can change under a running pytest at any
moment; a red run there may be about a tree that no longer exists.
Review economics, recorded because the numbers are the argument. Round one: four lenses over the
diff, each finding verified by an adversarial refuter. 31 findings raised, 19 confirmed and 12
refuted — and the refutations were substantive, one of them disproving a claimed process-group
escape by reading multiprocessing.util.spawnv_passfds on the machine. Two of the confirmed
findings were defects I had already found myself; the other seventeen I had not. The mutation
battery grew from 15 to 24 mutants over the same increment, all killed. The refutation stage
earned its cost twice over: it is what makes 19 findings worth reading rather than 31 worth
arguing with.
When a constant cannot be declared, constrain it (20260823)¶
The echo class — a gate reading a constant out of the document it polices — turned up four times in one gate, and the interesting part is that the same remedy did not work every time.
| Echo | Exploit | Remedy |
|---|---|---|
| sequence start = its own oldest entry | delete the oldest row; the start moves and hides it | declare it |
| lagging ceiling = its own newest entry | delete the newest entry; the ceiling drops with it | bound how far it may lag |
| Part range = its own heading | append a range to # Part 5 and a misfiled section is legal |
constrain it |
| Part count floor = one below the truth | demote # Part 5; the floor passes exactly |
raise it to the real count |
The third could not be declared, and that is the lesson. Reading Part ranges out of the headings
is why the mapping cannot drift from the document — replacing them with a table in the tool would
reintroduce the drift the gate exists to catch. So the echo had to stay and be made unexploitable
instead: two Parts may not claim the same version, and the Parts must ascend. # Part 4 declaring
`0.8.0` onward is then precisely what stops # Part 5 declaring it.
Declare, bound, constrain — in that order of preference. Declaring is strongest and cheapest when the value is genuinely external. Bounding admits the echo and limits its travel. Constraining leaves the echo and removes the freedom that made it exploitable. Reaching for the first when only the third is available produces a table nobody updates.
A floor one below the truth is a floor with a bypass. PARTS_MINIMUM was 4 against 5 real
Parts, so demoting the last heading passed it exactly while handing every section beneath to the
open-ended Part above. Floors here are written as "this only ever holds, because things are never
removed" — which is an argument for setting them at the count, not below it. A floor with slack
is a floor someone can stand in.
And a test that asserts a sentence asserts only that something went wrong. Three instances in one increment, each found by mutation and none by reading:
- a range-form test asserting a failure — satisfied by breaking the form entirely
- placement fixtures guarded by
assert "reads ascending" not in stderr— satisfied by a reworded message, and by a second failure appearing beside the one under test - an ordering test asserting
"must ascend with the document"— satisfied with the comparison reversed, because a different, correctly-ordered pair then fires and prints the same words
The positive form in each case: assert exactly one failure and which one — the pair, the
Parts, the versions. failures_of() exists for that.
On the audit itself. These were found by four independent lenses over the landed gate, each finding then handed to a separate agent told to refute it — not by re-reading the diff. Two of the four lenses found nothing. The two exploits came from the lens that was asked one question only: for every constant this gate uses, where does its value come from?
A correction is a claim like any other (20260823 02:05)¶
Two sessions spent 20260822–23 finding one defect shape from four directions, and the fourth instance landed on the correction rather than on the code.
A handoff file said the release-order gate's placement and membership halves shipped in 0.27.1 at
ba4d7ae. That was wrong. The reviewing session corrected it to "placement and membership landed
after 0.28.0 and are unreleased" — which was also wrong. git describe --contains settled it in
one command:
09f5449 (placement) -> v0.28.0~2
0a8bd38 (membership) -> v0.28.0~1
ba4d7ae (lag + Parts) -> in no tag
The reviewer's own diagnosis is the thing worth keeping: one true fact was established —
ba4d7ae is tonight's merge, not 0.27.1 — and extended to a second that did not follow. The true
half made the false half feel checked. The evidence to settle it was one command away and was run
only after pushback.
This is the same shape as a fix applied under review inherits the review's confidence and none of its scrutiny (BUILDING.md § 5) and its sibling verify the remedy, not only the finding — one level further out. The correction is the least-reviewed statement in any review, because it arrives attached to a finding that has just been demonstrated, and demonstrating the finding feels like demonstrating the fix.
And a second rule fell out of the same exchange, stronger than the one it replaced. The file's
ownership table had a held last by column naming sessions. The reviewer said "put the right name
in". The right name could not be kept: the writing session was renamed mid-file, and the two
sessions' ListAgents views disagreed about which of them existed. So the column was deleted, not
corrected — ownership is by role, with ListAgents for who is live.
When a field cannot be kept true, remove the field rather than maintaining it.
That is the end of the ladder this repository has been climbing all week: declare a constant rather than deriving it; bound it when it must be derived; constrain it when it must be read from the thing it polices; and delete it when none of those can hold it honest. A field nobody can keep true is a lie with a maintenance schedule.
A document's version stamp is the one claim in it nothing can check¶
docs/GUIDE.md opened with "Every command here was run against 0.2.0 (20260728 16:40)" and was
published, unchanged, for twenty-six releases. Nothing was broken by it and no gate could see it:
./check.sh proves the release sequences agree across five documents, mkdocs --strict proves
every link resolves, and tools/template_drift_gate.py proves the template matches its archive —
none of them reads a sentence claiming a command was run.
The stamp is not a small lie. It is the sentence a reader uses to decide whether to trust the other
eight hundred lines, and it was wrong in the direction that costs most: it claimed more
verification than had happened. Nine output blocks had drifted behind it, and the two most
misleading were the ones a reader would act on — a euro estimate quoted 30% high, and three separate
statements that only one surface in Pinakes can spend money when pnk ask --deep had been the
second for four weeks.
The pattern is that a stale doc rots fastest exactly where it was most specific. Prose survives a release; a quoted output block does not, because it pins a number, a version string and a wording all at once, and any one of the three moving falsifies it. Nine blocks drifted while the paragraphs around them stayed true. That is an argument for keeping worked examples — they are what catch the drift — and against ever writing one without a way to re-run it.
Two things this pass could not do, and said so rather than faking. The paid --deep transcript
would cost real money to reproduce, so it is kept and labelled as a 0.24.0 run with the one figure
that has since moved named explicitly. And re-running the two-KB link walkthrough would change every
ULID in the section without making a sentence truer. The distinction that matters is between an
output nobody re-ran and an output nobody re-ran and did not say so. Only the second is a defect.
A near miss worth recording. The first draft of the label said the paid block's command "is now
quoted at €0.20" — the figure measured at -k 2. The block's own command passes no -k, and at the
default passage count it quotes €0.21. One cent, and the wrong kind of wrong: two invocations
collapsed into one number, which is precisely the invented precision the label existed to prevent.
Caught by re-reading the command the block actually shows instead of the one measured beside it.
A gate can be green because it is reading the list next door¶
The Published versions row drifted four releases while a check named after it ran green on every commit. The check was real, the row was wrong, and both statements were true at once: the gate's sixth sequence reads the Published on PyPI prose, and the row is a different list forty lines down in the same file, under the same heading.
What made it invisible was the naming, not the code. A reader auditing "is the PyPI list checked?" finds a sequence called the Published on PyPI prose, sees it green, and stops. Nothing in that report says which of the two lists under that heading it read. The lesson generalises past this file: when one document holds two lists of the same thing, a gate covering one of them reads as covering both, and the coverage claim is what needs the test — not the ordering.
The corollary for within. Scoping a pattern to a region introduces a failure the line-anchored
sequences never had: the anchor can match somewhere else. Zero matches is safe, because an empty
sequence trips the floor and the floor's message fits. Two matches is not, because reading the first
is a silent choice — the anchor deciding for itself which region it meant, which is the
"derived, never declared" mistake this module refuses everywhere else, one layer down. So it is a
refusal, not a heuristic.
And a fixture proves scoping only if it would break without it. The tree these tests build
writes version numbers after the enumeration and in descending order, mirroring the real cell. A
within that stopped scoping reads them into the sequence and the tests go red. Without those
trailing numbers every test here would pass over a bare unscoped pattern — the fixture would be
asserting the thing it was built to assume.
On the floor: an exact count is declared, and still wrong here. Setting the floor to the 41
versions present would be a literal, not a derivation — but check() skips a sequence whose floor
failed, and check_membership then skips it too, so deleting one release would report "the pattern
has stopped matching what it names" and never say which release went missing. The precise
diagnostic is worth more than the redundant one. Declared-not-derived is about where a number comes
from; it does not settle what the number should be.
A constant bounds the damage; a relation catches the start¶
The first fix made the row a sequence with permission to lag, bounded by MAX_VERIFICATION_LAG.
That is a real check and it would have caught both drifts — eventually. Counting the history says
how late: 29 commits sat in the window where the row was behind and the bound was silent, and both
drifts passed through it on the way to being caught.
The bound is a constant, and the thing it approximates is a relation. The row may lag the release documents because an entry waits for index verification; it may not lag the other list recording that same verification, because there is no interval during which one is true and the other is not. Written that way the rule needs no tolerance at all, and the measurement bears it out: zero false positives in 67 commits, red at the first commit of the drift rather than the eleventh.
The general form: when a bound needs a tolerance, ask what the tolerance is standing in for. A number chosen to be loose enough for the legitimate case is by construction also loose enough for some illegitimate ones. If the legitimate case can be named — here, "both lists are waiting on the same verification" — the relation that names it is both tighter and simpler than the number.
Keep the constant anyway. It is not redundant: it catches a drift in which both lists are forgotten together, which the relation cannot see. Two checks over one sequence, catching different failures at different moments, is the correct outcome — and the test that exercises the deep drift now asserts both fire, so neither can be deleted while it stays green.
Committing the mutation batteries — a design statement nobody had measured (20260823 07:34)¶
HIGH — the sentence that decided this was an assumption wearing a docstring. tools/mutate.py
shipped in 0.27.0 saying "a battery is a per-increment working file, not a portable artifact", in
a paragraph whose actual subject was where the pytest key lives. Nothing had measured it, and it
then decided the question for two months by being read rather than by being right.
plans/20260821_0745-mutation-harness.md enumerates what it deliberately left open and persistence
is not among them — checked, not assumed. So this fills a gap rather than overturning a position.
The measurement took twenty minutes. Of 81 mutants across six increments, 78 anchors still
resolved exactly once a day to a week later, and every one that broke was one whose own code had
changed. The failure mode of keeping a battery is a refusal, not a wrong answer — mutate.py
resolves every anchor before it writes anything and refuses on 0 matches and on 2. That asymmetry is
what made the question decidable. An argument about whether a claim outlives its proof would not
have resolved; what does being wrong cost? did.
HIGH — and the measurement was aimed at the half that had never broken. docs/RETROSPECTIVES.md
already recorded which half rots, twice in one increment: "a battery is source that goes stale like
any other, and a SURVIVED row is a claim about a pair — the mutant and the selector — either half of
which can be wrong." Both my 81-mutant recovery and a peer's five refactor scenarios stress-tested
anchors. The selector is what historically went stale. The gate resolves both, and the half
that matters is the one the headline number says nothing about. Check the record for which half
failed before designing a measurement of the other.
HIGH — the repair loop produced two broken mutants under best-case conditions, and that is the
strongest evidence against the model, not for it. Two anchors were repaired here: same day, by the
author, with full context. Both widened old from one line to two and left new at the one line
it used to replace:
| Repaired mutant | What the edit actually did |
|---|---|
| the prose list loses its exemption | deleted minimum=15, and flipped the flag — two changes under a name promising one, and the floor it fell back to sits on the boundary of that sequence's length, so the test may have gone red for the floor |
| the prose sequence is read in the wrong direction | deleted starts_at and duplicated minimum=15, — keyword argument repeated, a SyntaxError at import |
The second read KILLED, in a batch reporting 0 errored. classify treats a collection error
as the invalid mutant, and this module is imported inside the test rather than at module scope,
so the SyntaxError arrived as an ordinary assertion failure. A confident row about a question nobody
asked — the one thing the tool exists to refuse — produced by the tool, in its own corpus, on the
first day. n=1 in the direction an adversary predicted, and it is the honest cost of the model.
refuse_a_mutant_that_cannot_compile now catches the syntax half, before the first write, and it
earned itself within the hour: restructuring check_anchors staled two mutants in this
increment's own battery and it caught both. ast.parse would not have — it accepts f(a=1, a=2)
and compile() does not, which is exactly the case that got through, so a test pins the
discrimination rather than leaving the choice of compile looking like a detail. The other half
has no guard: an edit that still compiles and changes two things under a name promising one is
invisible to everything here. Read the diff a mutant produces, never its anchor.
MEDIUM — a corpus with no inventory can shrink to nothing with every gate green. None of the
first draft's checks was a count, and the sanctioned repair for a property that has genuinely gone
is to delete its mutant — which makes deletion the cheapest path to green during a refactor. Split
a 739-line gate six weeks from now, watch thirteen anchors resolve zero times, delete thirteen
blocks: ./check.sh green, the battery reads 28 of 28 killed, and nothing anywhere recorded
that it was 41. mutants = N per battery, validated in load_battery, makes deleting a row a
two-line edit that says so.
MEDIUM — the argument for keeping them was weaker than it first looked, and saying so changed
which argument to make. The first draft argued the claim outlives its proof. It does not:
re-deriving 41 mutants against a gate is an afternoon. What is unrecoverable is the reasoning
about which mutants were worth writing — and the durable channel for that already existed. A
per-mutant table can go in a retro.d/ fragment, and docs/RETROSPECTIVES.md has exactly one,
against 93 commits that speak of a mutation pass. The channel existed, worked, and was used once.
That is the case for a directory, and it is a better one than there was no channel.
MEDIUM — consolidation is a check that scattering cannot perform. Merging six scratchpad
batteries into three files surfaced what no individual battery could: one battery entirely
superseded by a later copy of itself, and two mutants byte-identical in file, old, new and
kills under different names — the same edit counted twice in a report reading 29 killed.
MEDIUM — a directory nothing reads is the failure this repository keeps recording. A template
version that meant different bytes in every commit; a verification table where 61 of 98 test paths
did not resolve; a docs/GUIDE.md whose commands no longer ran. So tests/test_batteries.py reads
them, in 0.05s. Its cost, stated rather than discovered later: a refactor that moves a mutated
line turns ./check.sh red until the battery is repaired — measured at 2 repairs per 81 mutants per
week, minutes each. The assertion names re-anchoring before deletion, because deleting a mutant
to green a gate is the silent damage this arrangement invites.
MEDIUM — the corrections a review pass made to this file's own claims. Each was a number stated with more confidence than the data carried:
- "the single comment-bearing anchor in the set was the one that went ambiguous" — wrong on both halves. Three of the 81 anchors carry a comment; the one that went ambiguous carries none. One comment-bearing anchor did rot, in the superseded battery. The generalisation drawn from it — that the fragile shape is knowable in advance — went with it.
- The mcp battery was described as spanning six files. It spans seven;
check.shwas in neither enumeration. - The section for
tools/fragments.pywas labelled 0.28.0; the property shipped in 0.27.1. - Its header was a comment that had migrated during consolidation, claiming "the two gates" and a control that had gone to the other file. A comment attached to the wrong mutant is silent, and the comments are the reason these files exist.
- A mutant named "ci.yml guards the gate with
|| true" appended&& echo ok, which does not swallow the failure the name promises. The name is the only guard against an anchor that still resolves while the semantics moved, so a name that misdescribes its own edit disarms it.
MEDIUM — "regression asset" is the one thing this cannot be, and the README now says so.
report() exits 0 when mutants SURVIVE, deliberately — "It is not a harness failure, so this exits
0 — read the rows." Nothing here changes that, and the committed gate never runs a battery: it
checks that strings resolve. Anything CI-shaped built on top needs its own check on the survivor
count. And the denominator belongs beside the claim: four batteries, four targets, all under
tools/; no module under src/ has one, and no invariant in docs/INVARIANTS.md is covered.
The covered files change 3–11 times a month, cli.py 58 and sync.py 40. A coverage index with a
hidden denominator is the exact defect this repository keeps catching in its own gates.
LOW — --check-anchors reads the working tree, not HEAD, and the difference is the point. A
run refuses an uncommitted target so git checkout <file> stays a safe recovery after a hard kill.
Nothing is written in check mode, so inheriting that refusal would have bought nothing and disarmed
the check in the one moment it is wanted. Two guards that look alike had different reasons, and
copying the reason across would have made the second useless.
LOW — three ways the first draft of a "report every failure" contract reported fewer. The test-file refusal raised inside the per-battery loop, discarding the problems already collected and never reading the batteries after it — which is the run's behaviour, and the thing this mode is defined as not being. The claim set keyed on the battery path as typed, so one battery named twice under two spellings was a false double-claim. And stdout was flushed only on the problem path, so a refusal printed above the summaries it contradicted.
LOW — the flagship test passed when half of what it pins did not happen. The two stale mutants
were named gone and also gone, and "gone: …" in stderr is satisfied by the also gone line
alone. Reporting only the last problem passed a test whose docstring calls reporting every
problem "the difference from a run that matters most". Two fixture names where one is a substring
of the other is a silent half-assertion, and the same shape hides anywhere a test asserts on
substrings it also chose.
LOW — the test helper that wrote both batteries to the same filename. battery() hard-coded
battery.toml, so the first "two batteries" test built the second by calling it again and copying
the result — silently overwriting the first. The overlap test passed anyway, because both files
claimed the same target either way; the control — two batteries claiming different files — is
what exposed it. A control earns its place by failing for a reason the pin cannot.
Extracting CLAUDE.md — four agents each edited correctly, and the set was wrong (20260823 13:52)¶
HIGH — every defect in this increment was a neighbourhood defect, and none was a content defect. Four agents wrote four destination sections in four files. Each one did its own job correctly: right heading, right anchor, every source fact carried, matching voice, no existing heading renamed. The review then found five defects, and all five were about what the surrounding text already claimed:
| Where | What it still said |
|---|---|
docs/RELEASING.md:3-5 |
"the traps … stay in CLAUDE.md" — one had just moved into this file |
docs/RELEASING.md:7 |
"Extracted … 20260801 … Nothing was dropped" — a provenance note now silently covering content added 20260823 |
docs/INVARIANTS.md:6-9 |
described the file's two structures; there were now three |
docs/README.md:83 |
the routing table still described the pre-extraction layout |
docs/BUILDING.md:168 |
pointed at CLAUDE.md's text, which is now the compressed version |
docs/README.md § Conventions has said audit the neighbourhood, not the diff since it was written.
A per-file agent cannot follow that rule, because the neighbourhood is the thing outside its
file — and the one file that most needed editing, the routing table, was assigned to nobody and so
was the only docs/ page absent from the diff. If work is fanned out per file, something has to
own the seams, and it is not any of the writers.
HIGH — the extraction nearly created a second home for a rule, which is the failure it exists to
prevent. A section was written into RELEASING.md for naming unbuilt work — and
docs/README.md § Conventions already owned that rule in full, including the same date, the same
STATUS.md pointer and the same historical-records carve-out. Worse than duplication: the pointers
formed a loop in which no file admitted README held it. README said the live names are in
CLAUDE.md; the new section said the rule and the table live in CLAUDE.md; CLAUDE.md pointed at
the new section. A reader following the pointer from CLAUDE.md would never reach the copy that
actually owns the rule, and the two could drift with every command reporting success. The section
was deleted and CLAUDE.md now points at README. Before writing a destination, grep for the fact —
a relocation into a fact's existing home is a duplication, not a move.
MEDIUM — and checking that duplicate turned up a stale claim nobody was looking for.
docs/README.md § Conventions still named the deep release as live unbuilt work. It left that
table at 0.26.0 — its final cut — three releases ago, and CLAUDE.md's own table had been correct
all along. The duplicate was not merely redundant; the two copies had already drifted, which is
the concrete form of the harm "one fact, one home" is stated to prevent.
MEDIUM — four dead links, written while creating pointers whose entire purpose is to be followed.
CLAUDE.md sits at the repository root, so a relative ](RELEASING.md#…) resolves to /RELEASING.md,
which does not exist. Four were written that way. mkdocs build --strict cannot catch this class:
CLAUDE.md is not part of the built site, so its outbound links are checked by nothing. The check
that found them is three lines of Python — parse every ](path.md#anchor) out of the file, slug
every heading in the target, compare — and it belongs in any future extraction.
LOW — the honest outcome is 220 lines, not 150. The guideline is ~150 and this file is at 220 after everything with a genuine home elsewhere was moved. Going further would mean deleting rules the user set, and rule 6 says explicitly that the guardrail is "the trigger to extract sections and leave pointers — not a hard cap that justifies deleting information." Reporting the shortfall is the correct outcome; compressing past it would have been the defect the rule warns about. What remains is dense by construction: a role rule, an ownership table, a naming table where every row is a breaking change, and four sections of user-set working mode.
The ungated link surface, and three instruments that lied (20260823 14:38)¶
HIGH — a regex cannot gate Markdown links, and both agents on this repo built one first. The
coder's first scan reported 82 broken links inside docs/, a surface mkdocs --strict guarantees
clean; it had blanked inline code with re.S, so one unbalanced backtick in a 2 616-backtick file
swallowed every heading after it. Mine then reported 18 where the true number was 11 — twice, for
two different reasons. Rendering answers the question directly: if a link is inside a code span
the parser emits no <a> at all. No stripping, no heuristics, no reimplementation of block
parsing. The final instrument is markdown.markdown() plus this repo's own _github_slugify, run
through the same uv run --no-project --with-requirements requirements-docs.txt invocation
make docs uses, so it cannot drift from the site.
HIGH — a control leg validates only the behaviours it exercises, and a green one is not a clean
bill. The coder's control caught its re.S bug. Mine did not catch either of mine: 485 links
across docs/, zero broken, green — while the instrument was wrong twice. docs/ contains no
links to a directory, so Path.is_file() rejecting docs/graph/ and ../plans/ was never
exercised; that produced six false positives. And docs/ has no indented code block containing a
link, so treating one as prose was never exercised either. The second false positive produced a
wrong edit before it was caught: retro.d/README.md:37 sits in a four-space block and is the
README teaching fragment authors what to write — a bare #anchor, which resolves only once the
fragment is spliced into docs/RETROSPECTIVES.md. It was repointed at ../docs/RETROSPECTIVES.md
before the surrounding prose was read, turning an instructional example into one that teaches the
wrong form. Reverted. Read what a line is for before fixing what a tool says about it.
MEDIUM — an adversarial pass that overturns nothing has said nothing. Re-verifying the
20260807 audit's 39 findings against c45ffa8 ran eleven agents plus an adversary per cluster,
each prompted to refute every already fixed verdict, since that is the direction that silently
drops a verified defect. The adversary overturned zero. Hand-checking all seven afterwards found
one false: docs/DESIGN.md:711 asked to drop two phrases and only one had gone — a manifest and
a template drift silently still stands while doctor.py prints the drift it denies. The adversary
was prompted for exactly that shape, corrected in one place while an equivalent claim survives,
and upheld it anyway. A zero from a checker is a claim about the checker. The same pass also
corrected two predictions made from reading the code without reading the document: DESIGN.md:715
and KB-UPDATES.md:24 were called more wrong than before on the grounds that pnk upgrade had
since shipped, when both had in fact been rewritten and are genuinely fixed.
MEDIUM — mkdocs --strict is a link gate, not a rendering gate. It resolved every link in
docs/RETROSPECTIVES.md for weeks while one of its lines rendered as a broken run of <code> tags
on the published site. The defect class — a backslash-escaped backtick inside a code span, which
CommonMark does not honour — produces valid HTML that is simply not the HTML anyone intended, so
nothing that checks structure can see it. Catching it needs a rendering comparison, which is a
different check from link resolution and was found only because a link fix forced the line through
a parser.
The ungated Markdown surface, and two checkers that were confidently wrong (20260823 14:36)¶
HIGH — a checker with no control leg reports plausible nonsense, and reads as authoritative.
The first draft of tools/markdown_link_gate.py reported 82 broken links inside docs/ — a
surface mkdocs build --strict guarantees is clean. The cause was a one-flag mistake: inline code
spans were blanked with re.S, so in docs/ROADMAP.md (2616 backticks, zero fences) a single
unbalanced backtick swallowed every heading after it. Nothing about the output looked wrong —
missing anchors, real filenames, plausible slugs. It was caught only because the scan was run
over a corpus whose correct answer is known independently of the checker. Run a new checker against
a surface something else already guarantees, before believing anything it says about the surface
nothing guarantees.
HIGH — a control leg validates only the behaviours its corpus exercises. A peer wrote an
independent scanner over the same surface and got 18 where this one got 12. All six extras were
its bug: it resolved targets with Path.is_file(), so every link to a directory failed —
docs/graph/, ../plans/, ../retro.d/ — each correct as authored, since GitHub renders a
directory link as a listing. Its control leg over docs/ stayed green throughout, because
that corpus contains no directory links. So the two lessons are halves of one: a checker with no
control reports nonsense, and a green control certifies only what it covered. Both instruments
were wrong, in different directions, and each was found by something the other did not do.
HIGH — a false positive gets acted on, so the skips matter as much as the catches. The count
was reported as twelve and was eleven. retro.d/README.md:37 sits in a four-space indented
code block — neither this checker nor the peer's stripped those — and it is the README's own
example teaching fragment authors which anchor form to write. The peer repointed it before reading
what the block was for, turning an instructional example into one that teaches the wrong form,
then reverted. Nobody argues with a false positive the way they argue with a false negative; they
fix it, and the fix damages the document. A link checker's skip list is a correctness surface.
HIGH — when a cheap instrument is suspect, do not argue about it: render, and measure the gap.
The right response to "a regex cannot parse Markdown" was not to defend the regex or to take the
dependency, but to build the renderer once as an oracle and compare. Python-Markdown over all
114 tracked files emits 894 links; the extractor now finds every one of them and invents zero,
the single difference being a bare <https://…> autolink that is external and never resolved
anyway. That measurement is what licenses staying stdlib-only — ./check.sh and the CI job need no
install and no network — and it is kept as a test that runs wherever the docs toolchain is present
and skips with its reason where it is not. The argument was unresolvable; the measurement took one
command.
MEDIUM — a false negative is the failure mode a link checker actually has. The second bug in
this one forbade newlines in link text, which silently skipped every link whose text wraps — in a
repository that wraps prose at 100 columns, and where CLAUDE.md wraps seven of them. A false
positive gets argued with; a false negative is a clean bill nobody earned. The control leg cannot
see it, because a control proves the absence of false positives only. The gate therefore counts the
link syntax it saw and fails on anything it could not parse, rather than skipping it.
MEDIUM — the two documents most worth gating are the two the gate cannot fix. All eleven
findings sat in planner-owned files (CHANGELOG.md, plans/, retro.d/README.md), so the gate
was buildable and the defects it found were not — by the same ownership rule that makes the split
work. Coordination, not escalation: the findings went over as a list with evidence and line
numbers, and the fixes landed on the other side of the boundary while this branch built the gate.
LOW — six of the eleven were quotations, and that is a design constraint rather than noise.
plans/20260807_2143-docs-audit-findings.md quotes other documents' links verbatim; they are
correct relative to docs/ and dead relative to plans/, and correcting the path would falsify
the quotation. A gate that flagged them could only be satisfied by corrupting the quote. Code-span
a quoted link and it is inert — which the audit file already did in one place before anyone asked.
Landing beside a peer — the overlap tool answers a different question (20260823 14:57)¶
MEDIUM — a check named for a problem is not a check for that problem.
tools/shared_file_overlap.py --fetch --strict reads as the collision check between concurrent
sessions, and two sessions ran it that way for a full afternoon. It compares a branch to
origin/main. It never looks at another branch, so it cannot see a peer at all: it reported
none while a coder held 20260823_1424-markdown-link-gate. It was not wrong — the file
intersection genuinely was empty — but it could not have been right, because it was answering
"will this merge cleanly" and being read as "is anyone else in these files". The remedy is a
comm -12 over the two branches' file lists, which is three lines and answers the actual
question.
MEDIUM — the landing order can be forced, and asking your peer will not reveal it. The
convention is that complete, gated work lands first and the other rebases, which frames order as
something two sessions agree. Here it was determined by a gate. The coder's new
tools/markdown_link_gate.py reported 11 broken links and exit non-zero against main, so
its own ./check.sh could not go green until the planner's link fixes landed — its branch was
unable to land, not merely second in a queue. Neither session knew. The coder was running the
gate on its own tree, where it passed; the planner found it by copying the gate out of the peer's
branch and running it against main and against its own worktree. Run the peer's gate, in both
places. A peer's report of its own state is honest and still blind, because the failing
configuration is the one it never runs.
LOW — the same window bites twice, so it is a property of the procedure and not of one tool.
Both sessions' link checkers crashed with FileNotFoundError on a file git ls-files lists and
the disk does not have. That state is not exotic: docs/RELEASING.md step 1 splices fragments and
deletes them, and ./check.sh runs before the commit that removes them from the index. Any gate
enumerating tracked files and opening each one meets it on every release. A tracked-but-absent file
is a normal mid-release state, and the handling is a skip.
The gate that read everything except its own output (20260823 15:11)¶
tools/fragments.py --check had run in check.sh since 0.24.0. It parsed every pending fragment —
category, slug, emptiness, a leading --- fence — and asserted nothing about the document
--apply produces. Three malformed regions sat in CHANGELOG.md through every green run, and the
one that names the class was found by reading a release precedent, not by any gate.
The shape is worth naming, because it is not a missing check — it is a checker pointed one step
short of the thing it exists to protect. Every other gate here reads rows: mkdocs --strict
resolves links, release_order_gate reads sequences, markdown_link_gate reads link targets. A
duplicated heading is a property of adjacency, so all of them walk straight past two identical
headings in a row. Nothing in this repository had ever read the assembled file at all.
The bug this increment shipped, and what caught it¶
The first draft anchored its fence detector at column zero. CHANGELOG.md contains no
column-zero fence; its only fenced block is indented two spaces inside a bullet. So the scanner
skipped nothing in the one document it most needs to read, and a changelog entry demonstrating a
Markdown heading would have been refused as document structure.
It was caught by the test written for it, before landing — the false-positive test, not the
true-positive one. Both were written in the same pass; only one of them found anything. A passing
true-positive says nothing about what the checker is failing to look at. That is the argument for
writing the over-firing case even when the under-firing case is the one the item describes: a gate
refusing a correct document is the failure that gets acted on, and markdown_link_gate's own
docstring records a peer rewriting an example into the wrong form to satisfy a false positive
before disbelieving it.
What the false-positive probe found instead¶
Probing the - rule against Markdown a changelog author might plausibly write turned up a defect
that has nothing to do with this gate. The 0.24.0 front-matter residue — --- / category: added
/ ---, spliced verbatim before check refused that shape — is not inert text. A --- after
a text line underlines it into a setext H2, so each residue renders as a heading titled with
its own metadata. There are five, not the three this module recorded: three in CHANGELOG.md
under ## [0.24.0] and two in docs/RETROSPECTIVES.md, which the original count missed because it
looked only at the stream the refusal was written for. The published site carried
<h2 id="category-lesson">category: lesson</h2> twice — page, permalinks and search index. All
five were removed at 9718aaa, in the same afternoon and by the agent that owns those documents.
Every instrument is green on it. mkdocs build --strict resolves links and a spurious heading is
not a broken link; markdown_link_gate reads link targets; and this increment's own gate misses
it, because it reads ATX headings and this is setext. Which is the same sentence as the item it
closes, one level up: a defect that exists only in the assembled document is invisible to every
instrument pointed somewhere else in it. The documents are the planner's to repair and a setext
rule is a plan decision, so this increment recorded the measurement and changed no behaviour.
The mutation pass, and the survivor that was not mine¶
Twelve mutants, and the first run reported ten killed and two survived. Both survivors were real.
| Mutant | First run | Why |
|---|---|---|
the front-matter refusal no longer reaches --apply |
SURVIVED | 0.27.1's row, de-fanged by this increment. The document gate gave --apply a second reason to refuse that same fragment: a spliced --- is not a - list item either. Every assertion in the test stayed true through the mutant |
| the bullet rule reaches the free-form stream | SURVIVED | my own discriminating row. The fixture was built from ## headings and the bullet rule only ever reads a ###, so the scoping guard could be deleted with the test still green |
The first is the one to remember. Adding a guard can silently un-pin a different guard, when both refuse the same input and the test between them asserts only that it was refused. Nothing about the front-matter check changed; a test that had killed its mutant for three releases stopped, because a second correct behaviour started covering for it. A test that cannot say which guard fired pins neither, the moment a second one exists. It now names the refusal.
The second says the ordinary thing about fixtures: a fixture that omits the construct the rule
reads exercises nothing. The real docs/RETROSPECTIVES.md carries thirty-four ### headings,
every one opening with prose — the fixture now carries one. Re-run: 12 killed, 0 survived.
What the adversarial pass found, and the shape all three share¶
Five lenses over the diff. Three real defects, none of them in the two rules the item decided —
all three in the machinery around them, and every one reproduced end to end (written, fragments
deleted, exit 0, follow-up --check green on the wreckage) before it was touched.
| Defect | Why it was invisible |
|---|---|
_merge_into_section spliced an entry inside a fenced code block |
The checker skips fences; the splicer did not. A column-zero ``` holding ### Added was a heading to one and not the other |
splice would take a quoted ## [Unreleased] as the insertion point |
Same disagreement, one function up — it would bury every future release inside a code block |
--apply was not atomic across streams |
Refusing on the second stream wrote the first and deleted its fragments, then exited 1 saying "Nothing written, no fragment deleted" |
The first two are one sentence: a checker and the code it checks must agree about what a heading
is. Before this increment nothing read the document, so the splicer's fence-blindness had no
second party and was merely latent — genuinely harmless, for eleven releases. Adding a gate that
claims --apply cannot leave the document malformed is what converted a latent asymmetry into a
false claim, and that is why closing it belonged in this change rather than in a new item.
The third is the plainer lesson and the one with teeth: a refusal message is an assertion about the world, and it was false. "Nothing written" was true of the stream that failed and false of the run. A release step is one step or none.
The check stopped being read-only, and that is a cost worth naming¶
The item's stated defect is that --check "asserts nothing about the result of --apply". The
first draft read the file on disk — which asserts something about the result of the previous
--apply, and nothing about the next one, the one the sentence names. So the narrow draft
satisfied a weaker item. --check now validates the assembly the pending fragments would produce.
The evidence is the replay. Both instances the item cites came from a fragment whose body opens
with its own ### Fixed. Run against the tree as it stood at each of those commits, the widened
check exits 1 at the commit that added the fragment — at 0.6.0, whose duplicate was
hand-repaired seven minutes after release, and at 0.28.3, twenty-two days before anyone noticed.
Both exited 0 before, and the failure surfaced at release time — which is exactly when
docs/RELEASING.md has somebody weighing whether to hand-edit a document to get a release out.
And the cost, which a peer named and this increment had not. --check is no longer a
read-only validator: it simulates the write. So --check and --apply must agree about assembly
forever, and a disagreement between them would be silent — --check green on an assembly
--apply would never produce. That is this increment's own failure mode, reintroduced one layer
up. It is not an argument against the widening; it is the argument for making the two the same
code rather than two paths that agree: main calls prospective, the function --check
validates through, and a test imports the module by path and compares the predicted bytes against
what the real subprocess writes. A refactor that gives either path its own assembly turns it red.
Every new guard buys a new invariant. Three of this increment's four defects were the previous instance of that: a checker and a splicer that disagreed about what a heading is, harmless for eleven releases because nothing read the document, and false the moment something did.
The hole that was pinned rather than closed¶
The decided rule is adjacency, because that is the shape the evidence had. A heading that repeats after intervening entries is not caught, and there is now a test asserting so, naming the plan item. Widening the rule fails that test, which reopens the decision instead of quietly changing the gate. A named hole is a decision; an unnamed one is a discovery waiting to happen.
The assembled document nobody reads, and an extent computed after it was destroyed (20260823 16:18)¶
HIGH — a defect can exist only in the assembled document, where no instrument is pointed. Five
blocks of --- / category: <x> / --- survived a splice into CHANGELOG.md and
docs/RETROSPECTIVES.md. They are not inert: --- after a text line is a setext underline,
not a thematic break, so each rendered as an H2 titled with its own metadata — and two of them
have been served from the published site with live permalink anchors. Every gate was green.
mkdocs build --strict resolves links and a spurious heading is not a broken link;
tools/markdown_link_gate.py reads link targets; and the new fragments document-gate reads ATX
headings, so setext is outside it by construction. Three instruments aimed at these two files, and
the defect sat between all of them. The same shape as the item closed hours earlier —
fragments.py --check validating what it reads and never what it writes — which is the tell that
the class is the assembled document, not any one checker.
MEDIUM — a bottom-up edit destroys the markers a top-down extent depends on. The repair had to
delete each residue and re-bullet the entry it preceded, so each entry's extent ran from the
residue to the next structural marker. Deleting bottom-up is the standard way to keep earlier line
numbers valid — and here it was wrong, because a later residue is an earlier entry's stop
marker. The first pass deleted it, the extent then ran on into the following entry, and the result
indented a top-level changelog entry into a nested sub-item of its predecessor. Nothing failed.
No assertion fired; the residues were gone and the spurious headings were gone, so every check the
repair had written for itself said yes. It was caught by reading the rendered structure afterwards
and seeing one NESTED! where a BULLET belonged. Compute every extent against the untouched
document first, then apply. And the older rule that would also have caught it: read the diff an
edit produces, never its anchor.
LOW — the count was five and the record said three, because the record counted one stream.
tools/fragments.py's docstring named three residues in CHANGELOG.md. There were also two in
docs/RETROSPECTIVES.md, missed because the note was written from the refusal that motivated it,
and that refusal is changelog-shaped. A count taken from the thing that prompted the search
inherits the search's scope.
A closing note credited a fix that predated the defect, and the defect recurred twice (20260824 00:23)¶
tools/fragments.py spliced two consecutive ### Fixed headings into CHANGELOG.md at 0.6.0,
and again at 0.28.3, twenty-two days later. Between the two, the item describing it was closed.
The closing note named a mechanism that could not have fixed it, and the evidence for that was
already in git log when the note was written.
| When | What | Verified by |
|---|---|---|
| 20260729 04:20 | _merge_into_section introduced (0.3.0) |
9f7ed5c, the only commit git log -S'_merge_into_section' returns |
| 20260801 10:53 | 0.6.0 ships with two ### Fixed — three days later |
68ca96b; two headings under ## [0.6.0] at that commit |
| 20260801 11:00 | hand-repaired, seven minutes after release | 5920f41 |
| 20260803 22:21 | item closed, crediting _merge_into_section |
5bef91f, into plans/open-corrections.md |
| 20260823 04:13 | 0.28.3 ships the identical defect | 2f633a8 |
The closing row reads: "Fixed with a test (tests/test_fragments.py). _merge_into_section reuses
an existing ### Category heading, bounded to the anchor's own section…" — true of that function,
and irrelevant to the defect, because the row conflates two mechanisms:
what _merge_into_section fixes the section's *prose* already carries the heading
what actually kept happening a *fragment body* opens with its own `### Fixed`,
which `render` then wraps in a second one
Both recurrences are the second mechanism, and it is checkable in one command:
$ git show 68ca96b^:changelog.d/fixed-two-frozen-yaml-behaviours.md | head -1
### Fixed
$ git show 2f633a8^:changelog.d/20260823_0233-fixed-published-versions-row-…md | head -1
### Fixed
Three instruments that reported the previous truth, in one exchange¶
The same afternoon produced three more, all of the same shape — an instrument reporting the previous truth is indistinguishable from one reporting the current truth, unless you know which it is. None of the three is exotic; all three are the everyday tool used the everyday way.
| Instrument | What it actually answers | How it misled |
|---|---|---|
git fetch then reading origin/main |
what did I last hear from the remote | Reported a peer's landing as absent. origin/main is a local cache; only git ls-remote origin refs/heads/main asks the remote |
git log -S <string> -- <path> |
history of this path, stopping at renames | Returned only the rename commit for a plan file, dating a closure 20260804 instead of 20260803. In a repo that renamed every plan to YYYYMMDD_HHMM- retrospectively, every -S in plans/ inherits this; --follow or the pre-rename path is the answer |
awk '…{print NR}' fileA fileB |
cumulative record number across all files | Reported residues in docs/RETROSPECTIVES.md at lines 9718/9748 — the real lines plus CHANGELOG.md's 3957. FNR is the per-file number, and a line number carrying another file's length in it looks exactly like a line number |
The third was mine, in a message asserting a peer's work had not landed. Both halves of that message were wrong, from two different instruments, in the same sentence — and the report read as confident because each number was real. A wrong number that was measured is more persuasive than a guess, which is what makes this class expensive.
The cheap defence is the same in all three: name what the instrument reads before trusting what
it says. A cache is not a remote, a path is not a file's history, and NR is not a line number.
The lesson, which is about closing notes rather than about splicing¶
A wrong closing note is worse than an open item. An open item is a standing invitation to look; a closed one with a plausible mechanism beside it is a reason not to. It spends the evidence — the instance, the diagnosis, the commit that would have shown the dates — and buys nothing, and the next occurrence has to be rediscovered from scratch. This one was: 0.28.3's duplicate was found by reading a release precedent, not by any gate and not by the note.
The check that would have caught it costs one command. Every closing note names a mechanism; a
mechanism has a commit; a commit has a date. Is the fix older than the defect it claims to fix?
git log -S answers it in seconds, and here it would have returned a date three days on the wrong
side. A fix that predates its defect is not a fix — it is a coincidence with a plausible name.
The generalisation, and it is the third instance of one shape in a single afternoon: a green signal is only evidence about the thing it actually measures. A closing note measures whether somebody was satisfied, not whether the defect is gone. A passing true-positive test measures the case it constructs, not what the checker fails to look at. And a test asserting that something was refused stops pinning why the moment a second guard can refuse it too — all three were found here, and all three had been green for weeks.
Two precedents that agree are not a pattern (20260825 00:11)¶
HIGH — a decision arrived pre-framed by two real precedents, and the framing was refuted by the
three it did not mention. The question was whether the owed release was 0.31.0 or 0.30.1. It
was handed over as a genuine fork with evidence on both sides: 0.27.0 was MINOR for a new tool
file; 0.28.3 was PATCH for extending an existing one; 0.27.0's precedent is a new file, this is
not. Every clause of that is true. It is also the two cases out of ten that agree with each
other, and the criterion it implies — new file versus existing file — appears nowhere in the
governing SemVer rule, which says MINOR = new skill, command, capability, or feature and
PATCH = bug fix, doc update, or dependency/component change.
Measured across the ten tooling-only releases (those whose src/ diff is __init__.py alone):
| Claim | Verdict |
|---|---|
| new tool file ⇒ MINOR | refuted. 0.22.2 shipped a brand-new tools/release_order_gate.py as PATCH; 0.27.2 shipped a brand-new tools/wheel_import_gate.py as PATCH, with an ### Added section |
an ### Added section ⇒ MINOR |
refuted. Seven PATCH releases carry one; 0.18.0 is a MINOR carrying none |
| existing tool ⇒ PATCH | 4 of 4 — and the set is not vacuous, which is the check that mattered |
The load-bearing check was whether that last row was an artefact of confounding. If all four had
been ordinary bug fixes to a tool, the axis would have been "fixes are PATCH" and would have said
nothing about adding a capability to an existing tool. It was not: 0.27.1 added a brand-new
refusal to tools/fragments.py — this same file — and was cut PATCH under ### Fixed, and
0.28.1 did the same to release_order_gate.py. The one apparent counterexample, 0.29.0 putting
--check-anchors on the existing mutate.py under a MINOR, is confounded: that release's headline
was five new battery files and --check-anchors is not among its Added bullets.
The generalisation is about the shape of the offer, not about versioning. A framing assembled from two agreeing examples is the most persuasive form a wrong answer takes here, because no sentence in it is false. The defence is not scepticism about the examples — they survive checking — it is enumerating the population they were drawn from. The test set is the one you did not choose.
MEDIUM — the categorisation was the real question, and the implementer is the worst-placed judge
of it. The fragment was named added-…, and changelog.d/README.md makes the filename
authoritative for the assembler. But it was chosen by the session that had just built the thing,
labelling its own increment — the position with the least distance on whether what it built is a
feature. The entry was re-filed under ### Fixed on grounds independent of the version choice, so
the reasoning does not close on itself: it closes an item from a corrections plan, its body is a
defect narrative, and it is replayed against history to show it would have caught two past defects,
which is a fix with a regression test. A PATCH carrying ### Added has happened once in the modern
convention (0.27.2); this does not make it twice.
MEDIUM — %an cannot distinguish the agents in this repository, and the commit body can. All
1029 commits are authored Luca Gattoni <…>, committer identical. Measuring those fields and
generalising to "git cannot answer who wrote this" was wrong, and it was wrong in the specific way
this repository keeps rediscovering: the instrument was named after what it was hoped to answer
rather than after what it reads. The trailers are not uniform — 20+ distinct Claude-Session ids
and four Co-Authored-By models — and they are in %B, which no identity-field query touches.
git show -s --format='%B' <sha> | grep Claude-Session
That turns "was this closing note written by the session that did the work, or by a later one reconstructing it?" into a checkable question, which is precisely the class behind the two defects closed the day before: a closing note crediting a mechanism three days older than the defect, and a message asserting a peer's work had not landed. Both were written by someone reconstructing, and nothing in the repository could say so.
MEDIUM — a section heading is a status claim, and no gate reads it. A ### item in
plans/20260731_1202-open-corrections.md sits under ## Live while its own body, 26 lines down,
carries **CLOSED 20260824 00:35**. grep '^## ' — how anyone triages that file — returns Live
and stops. It cost a real collision: a coder session read it as live and was minutes from
rebuilding a landed increment. The convention it should follow already exists two files away —
T6 and T8 in plans/20260804_1016-template-release.md carry · **CLOSED <date>,
<disposition>** in the ### heading itself — so the repair is conformance rather than design.
Not done yet, deliberately: the item is partial, not closed (the body-rule widening is
explicitly untaken), so moving it into ## Closed — recorded so nobody reopens them would file an
undecided design question under a heading promising the opposite.
LOW, and it recurred twice in one session — a gate is only a gate when its exit status is what
the next command reads. make docs 2>&1 | tail -12 followed by echo "DOCS_EXIT=${PIPESTATUS[0]}"
printed DOCS_EXIT= — empty. The value read was the pipeline's, not the build's. Both instances
were caught and re-run bare, so what actually gated the release was ./check.sh and make docs
redirected to a log with $? read directly. The rule was already written, in CLAUDE.md, and had
been read that same session. A rule that is known and still broken twice in four hours is a rule
whose failure mode is invisible at the moment of committing it, which is the argument for the
redirect-and-read-$? form being the only form ever typed, rather than the one used when it seems
to matter.
LOW — a count word and the list beside it drift independently. docs/STATUS.md held two
instances of forty-seven and exactly one was wrong: the other sits inside 0.30.0's own
published-verification entry, where it was accurate when written. A replace_all would have
silently falsified a historical record. Same family as the | head truncation that shipped a broken
anchor a day earlier — the edit whose anchor matches more than once is the one that needs the
count taken first.
LOW — a peer renaming itself is indistinguishable from a peer dying. This session's name changed
from pinakes-d1 to pinakes-7c mid-work. A coder session correctly observed that pinakes-d1 had
left ListAgents, correctly inferred a usage-limit kill, and wrote a careful handover describing a
release as stranded mid-cut — while it was still moving. Nothing was lost, because the branch had
been committed and pushed at every step, which is the actual defence. But ListAgents cannot
distinguish gone from renamed, so a peer's absence is evidence about the listing and not about
the peer.
LOW, and found by a number that failed to move — tools/markdown_link_gate.py reads
git ls-files, so a file that is written but not yet staged is invisible to it. A full
./check.sh run immediately after writing this very fragment reported 84 ungated file(s), 301
link(s) — byte-identical to the run before the fragment existed. Staging it and re-running gave
85. So the ordinary sequence — write a fragment, run the gate, commit — produces a green that
says nothing whatsoever about the new file, and the first thing to actually read it is CI, after
the push. It is the same sentence as the item this release is about, one directory over: a green
signal is only evidence about the thing it actually measures, and here the thing measured is the
index rather than the working tree. Nothing was wrong with the fragment; the gate simply had not
been shown it. git add before the last gate run, not after — which is also the only ordering
under which the gate's answer describes the commit about to be made.
HIGH — a sweep for one direction of error is blind to its mirror, and the mirror is where the
confident mistakes live. A peer session swept plans/ for work filed as done that is not. Its
single strongest-worded finding was the opposite kind and it was wrong: sidecar.py:125-127 really
does accept a uniformly non-string-keyed mapping and silently coerce it, and Decision 19 really is
marked shipped in 0.5.0 — but the decision's own sentence reads "Top level only", so a
nested mapping is outside its scope by construction, and the behaviour is separately recorded at
plans/20260731_1202-open-corrections.md:205 under ## Not to be fixed — recorded so nobody
tries. Behaviour filed as never-to-be-done was read as undone.
## Not to be fixed is exactly as load-bearing as ## Closed, and strictly easier to miss: a
sweep hunting false closure has no reason to open it, and a vocabulary grep has no token for it. The
confidence came from having read the code, which was accurate; what decided the question was the
decision's own sentence, which had not been read. Withdrawn by its author on being shown both lines.
HIGH — the filter that decides what gets examined is itself unexamined. That sweep classified
each ## section LIVE / CLOSED / MIXED from its heading, and sent only the first two for a
semantic read. Nine of twenty files were dropped unread, and at least one wrongly:
plans/20260804_1844-decision-parent-child-arity.md's ## The decision was classified CLOSED while
holding the file's only outstanding work — a measured ceiling required before G5's gate runs. A
section named for a decision is not a section whose decision has been discharged.
Correction, 20260825 13:12 — the lesson holds, the example was wrong. The full re-read (X3) confirmed
## The decisiondoes hold live work, so the finding above stands. But it is not the ceiling: that was measured the same day and is recorded far earlier in this file, under theparent-childarity entry — locate it by that heading, not by a line number — at 53.42parent-childrows per chunk, 94.7% of stored edges, +113.4% index growth. The live item is requirement 3 — if the ceiling is alarming, the immediate-parent variant is the arm to measure — and no such arm exists (tools/graph_matrix.pyLEGS has seven legs and none is one). The sweep that found a heading-level claim was wrong then made a body-level claim that was also wrong, and it survived into a retrospective, which is the same defect one layer down. The number worth carrying forward is therefore 11 of 20 files read, not the finding count; a report's coverage is a property of its filter, and a filter that reads headings inherits every defect that headings have. That is the same shape as the item three paragraphs up — one instance found and generalised — one level higher: the classifier looked at eleven files and silently declined nine.
MEDIUM — the stale-section-under-a-current-banner defect has a second, larger instance.
plans/20260729_0256-links-and-graph.md's entire ## Baseline asserts a world that ended at
0.11.0, while the file's own banner at line 8 already says so. A reader going top-down meets
"G3 is the next increment to build" (:69), "Do not start any of them" (:82), "One cut
remains … it is blocked, not pending" (:103) and "G3 is the increment to pick up" (:124)
before reaching the correction. Verified against the tree rather than against the plan:
store.py:28 reads SCHEMA_VERSION: Final = 3 and the schema carries both nodes and edges, so
G3, G5 and G6 all shipped. None of it matches any status vocabulary; a grep finds nothing here.
And the fourth class is now demonstrated rather than hypothesised — work silently overtaken.
:642 states "Its exit criterion is not discharged by any test." It is:
tests/test_manifest_compat.py::test_a_manifest_requiring_a_newer_pinakes_names_the_version exists
and asserts exactly it. Something closed the requirement and never came back to say so, which no
search for a status word can reach — the sentence is a claim about the test suite, and only the
test suite can refute it.
The check that was not git — and the mutant that was not a test (20260825 00:14)¶
HIGH — a substring test stood in for git's ignore semantics, and it was wrong in both
directions. pnk init decided whether a KB's .pinakes/ could reach a remote with
".pinakes/" not in gitignore.read_text(). Four failure modes, all reproduced by running rather
than by reading:
.gitignore contains |
git ignores .pinakes/? |
init said |
|
|---|---|---|---|
.pinakes (no slash) |
yes | ⚠️ warns | false positive |
.pin* |
yes | ⚠️ warns | false positive |
.pinakes/ then !.pinakes/ |
no | silent | false negative |
#.pinakes/ |
no | silent | false negative, with a consequence |
The two false positives train a user to disregard the warning. The false negatives are worse
because they are silent, and the commented-out line is the cheapest way to reach one: comment it
out to debug, re-run init, and the ledger and every transcript are tracked with nothing said.
The general shape: a cheap proxy for an authority, where the authority was one subprocess away.
git already knows what git ignores — it reads nested ignore files, .git/info/exclude, the user's
global excludes and a parent repository's rules, none of which any amount of reading one
.gitignore can see. The proxy was not a simplification of the rule; it was a different rule that
agreed with it on the common case.
MEDIUM — the measurement trap that makes this easy to get wrong twice.
git check-ignore .pinakes returns not ignored for the pattern .pinakes/ whenever the
directory does not exist on disk: a trailing slash only matches a path git can already see is a
directory. At init time .pinakes/ has never been created, so the obvious probe answers
"unprotected" for the very pattern init itself writes. A path inside the directory carries the
directory in its own name and answers correctly either way. Anyone re-testing this by hand must
mkdir .pinakes first, or a correct pattern will look broken.
MEDIUM — check-ignore exits 0 when any argument is ignored, not when all are. Probing three
paths and reading the exit status would have called a .gitignore naming only the ledger full
protection, while the index stayed tracked. The matched paths have to be counted.
HIGH — the survivor was the useful half of the mutation run, and it found redundant code rather
than a missing test. Seven mutants; six killed. The survivor deleted an explicit
startswith("#") comment-skip from the offline fallback, and every test stayed green. Not a
coverage gap: "#.pinakes/".rstrip("/") is "#.pinakes", which already fails a whole-line
equality test, so the skip never decided anything. The code claimed a care it did not exercise,
and the docstring said so in as many words. The loop became one any(...), and the row now
mutates the property that is really load-bearing — whole-line equality against
".pinakes" in text, which is the shipped defect. A SURVIVED row is a claim about a pair, and
this time the wrong half was the code.
MEDIUM — the tests answered from the developer's machine. check-ignore honours a global
core.excludesFile and a system config. A developer with .pinakes/ in their personal global
ignore file — a sensible thing to have — turned three of these tests red and made two others pass
for a reason the code had nothing to do with (measured: 3 failed, 9 passed). An autouse fixture
points GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull. The production code still
honours those files, because a global ignore really does protect the ledger; only the tests must
not depend on which machine runs them. A test that shells out inherits the whole environment,
and the environment is not part of the fixture unless it is made to be.
HIGH — the replacement's first draft was a regression, and only an adversarial pass found it.
It probed three named files: index.db, ledger.jsonl, deep/transcript.json. A .gitignore
carrying *.db and *.json — an ordinary thing to write — ignores all three, so the new check
reported protected while index.db-wal stayed tracked, and in WAL mode that file holds
megabytes of verbatim document text. The substring test being replaced warned there. The
general shape is worth more than the instance: a check that answers about three filenames cannot
answer a question about a directory, and the docstring's careful reasoning that every probe must
match made the answer sound complete when the probe set was the incomplete part. A sound argument over
an unstated domain — every clause true, the population unexamined, which is the same skeleton as
the version framing that opened this session and as the status-word grep that swept plans/. Three
instances in one night, in three unrelated places. The probes are now opaque random paths only a
directory-covering rule can match.
MEDIUM — a reviewer's fix is a hypothesis exactly like the code it corrects. The
suggestion was two opaque probes, flat and nested. Measured against ground truth — git add -A
then git ls-files --cached, so the oracle never goes through check-ignore — that set still
reports protected for .pinakes/* with !.pinakes/cache, which tracks the entire extraction
cache. Four probes, two of them under cache/extract/ and deep/, is what passes all eleven
configurations. The review was right about the defect and wrong about the fix, and only
measurement separated those two — a distinction that disappears if a reviewer's remedy is applied
because the reviewer was right about the problem.
MEDIUM — a hand-rolled fallback disagreed with the thing it was standing in for. Outside a
repository the text scan read .pinakes/ followed by !.pinakes/ as protection — silently, on the
very input the in-repo tests assert must warn — and warned about /.pinakes/, which git honours.
Two definitions of one answer will diverge; the fallback now copies the .gitignore into a
throwaway repository and runs the identical probes, and reading the file is reached only when there
is no git at all.
MEDIUM — the increment had quietly widened its own trigger, and justified it with something
false. The check was made to run whether the .gitignore was adopted or written, on the reasoning
that an ancestor repository could negate what init wrote. It cannot: git resolves ignore rules by
directory depth, so the file written in the KB wins over any ancestor. What the widening did
reach was a path already in the index, where check-ignore reports not-ignored and the warning
would tell the user to add a line their file already contains. Narrowed back, and the message that
case produces was fixed rather than left: the printed remedy must never instruct an action that
would not change the verdict, which the old text could not violate — under a substring test the
string's presence was the verdict, so a redundant remedy was impossible. Asking git split
"unprotected" from "the line is missing" into two facts and the message went on assuming they were
one. A capability arrives with the failure modes it makes reachable, and they are invisible from
inside the change that adds it. Scope creep that survives is usually scope creep with a
plausible comment attached.
HIGH — two mutation survivors, and both were about the test rather than the code. The first
deleted a startswith("#") comment-skip and nothing went red: "#.pinakes/".rstrip("/") already
fails whole-line equality, so the skip decided nothing and the docstring claimed a care it did not
exercise. The second was len(matched) > 0 surviving against
test_ignoring_only_part_of_pinakes_is_not_protection — a test whose name is that assertion. Its
input had been a .gitignore naming only the ledger, which discriminated while the probes were
named files and stopped discriminating the moment they became opaque: no probe matches, so "any"
and "all" agree. A test can stop pinning the property it is named for without changing a
character, when what changed is the code it points at.
MEDIUM — the tests answered from the developer's machine. check-ignore honours a global
core.excludesFile and a system config. A developer with .pinakes/ in their personal global
ignore file — a sensible thing to have — turned three of these tests red and made two others pass
for a reason the code had nothing to do with (measured: 3 failed, 9 passed). An autouse fixture
points GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull. The production code still
honours those files, because a global ignore really does protect the ledger; only the tests must
not depend on which machine runs them. A test that shells out inherits the whole environment, and
the environment is not part of the fixture unless it is made to be.
MEDIUM — shelling out brought six failure modes that had nothing to do with ignore rules. No
timeout, so a wedged core.fsmonitor hook blocks init forever — after pinakes.toml is
written, leaving a KB the next pnk init refuses as already a KB. An inherited GIT_DIR answering
for an unrelated repository, which git exports to every hook it runs. Exit 1 read as an
authoritative not ignored when git had in fact printed a warning. text=True decoding strictly
inside subprocess.run, past except OSError. An inherited stdin. And a non-UTF-8 .gitignore
aborting with a traceback. Each has a test. The cost of asking an authority is that you inherit
its whole process model, not just its answer.
HIGH — 7/7 killed is a ratio with an unstated denominator, and it was read as an answer. The
battery was green about the code that had been written, not about the code that should have been;
the regression above sat outside every mutant in it, because the mutants were derived from the same
understanding that produced the defect. It was reported as "7/7 killed, fragments written" and
read as completeness by both the implementer who wrote it and the planner who received it. Neither
asked what the battery covered. A green battery is a claim about the mutants someone thought of.
The defence is not that someone should have been more careful — it is that a ratio whose
denominator is unstated should read as a question rather than as an answer, and this one names the
same skeleton as everything else here: a sound argument over an unexamined domain.
This is the first mutation battery under src/. Every other one covers tools/, which
tools/batteries/README.md records as a limitation rather than a design — "no module under
src/ has one, and no invariant in INVARIANTS is covered." The decision that keeps an index, a
spend ledger and a set of verbatim questions off a remote is a reasonable place to stop being able
to say that. Updating that sentence surfaced that it was already stale: it said "Four batteries,
four primary targets" with five on disk. The paragraph that exists to prevent a hidden denominator
had one.
Scope, deliberately held. Only the existing check's correctness changed — and its trigger, once
widened, was put back. Whether pnk doctor should carry a recurring check, and whether it reports a
warning or a note, is a decision reserved to the planner and the user, and this measurement changes
it rather than settling it: a detector that misfires on .pinakes and .pin* is a poor candidate
for firing on every doctor run, and a correct one is a much better one.
The diagnostic that printed nothing, and a review of a moving target (20260825 00:53)¶
HIGH — the fix for "a remedy that changes nothing" was an instruction that shows nothing. The
message printed git check-ignore -v .pinakes/index.db. That command reports only positively
matched paths, and the branch printing it exists precisely because the path is not matched, so
it emitted nothing at all. One defect replaced by the same defect one step along, inside the commit
whose subject line forbade the first.
The test is why it shipped, and the shape is general. It asserted the message contained the
string git check-ignore -v. Asserting that a message mentions a command is not a test that the
command helps. The replacement extracts the suggested line out of the printed warning, runs it,
and asserts both that it produces output and that the output contains the negation the user has to
remove. A test that reads a string can only ever verify the string.
MEDIUM — and the sentence describing the replacement overclaimed in turn, which is the fourth
instance today. The proposed docs line said the command shows "the ! that defeats the rule".
It shows the lines that name .pinakes — so when the defeating pattern is spelled some other
way (!.pin*, !.pi?akes/, a broader !*, all of which reach the same branch and were verified
to) the line that actually causes the problem is not printed. Caught by a reviewer running a case
the author had not chosen, which is what caught the three before it. The typical case described
as though it were the only case — the same shape as the probe set, the version framing and the
status-word grep, in four unrelated places in one night.
Left deliberately, and named here so it is not rediscovered as a defect. A diagnostic that
always shows the conflicting rule is a larger design question — cat -n .gitignore? a
check-ignore -v against a path git does match? — and it belongs with the rest of the reserved
gitignore work rather than in a release being held. The shipped command prints something real in
every case the branch fires, which is the improvement; it is not guaranteed to print the cause.
MEDIUM — a review is a measurement with a timestamp. The adversarial pass took long enough that the branch moved five times under it, and reviewers measured different commits: several findings labelled critical were already fixed when they were written, and the adjudicator had to re-run two survivor claims to find both already killed. Its own caution is the lesson — a long review of a moving branch measures a tree nobody will ever ship — and the response is not to review faster but to re-verify every finding against the tree as it stands before acting on its severity.
MEDIUM — the same test-hygiene defect, one layer further out. An earlier fix in this line
pointed GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull so a developer's global
excludes could not decide the answer. It closed the config leak and left the ancestor
repository open: with pytest --basetemp inside a checkout that ignores .pinakes/, three tests
failed and two of them are named outside a repository while running inside one. The fixture
covered the half that had been thought of — the same sentence as the mutation battery two hours
earlier, about a different artefact.
MEDIUM — two things were in one list because they share a prefix. GIT_DIR and
GIT_WORK_TREE redirect git at a different repository and must never be honoured here.
GIT_CEILING_DIRECTORIES only limits upward discovery, so honouring it makes this check agree
with the user's own git add and scrubbing it made the two disagree. Naming a constant for what
its members look like rather than for what they do is how the wrong one gets in.
And a note on the review arrangement itself, because it worked in both directions. The planner
amended a docs/CLI.md sentence specifically to stop it overclaiming what the code does — and
approved the correction by comparing the prose to the implementer's description of the code
rather than running the command. A description-versus-code check performed entirely on
descriptions. Caught within the hour by an independent pass, which is the argument for having one.
Planning the exposure question — a check that cannot see the case it is for (20260825 07:55)¶
HIGH — the 0.30.2 gitignore detector reports protected for a repository that is committing the
user's verbatim questions, and the reason is the fix that made it correct. The detector probes
opaque random tokens under .pinakes/, chosen on 20260825 so that no realistic pattern could
target them — which had been a real regression when three named files were covered by an ordinary
*.db/*.json pair. But a path that does not exist cannot be in the index, and
git check-ignore consults the index: on a tracked path it answers not ignored, and only
--no-index answers ignored. So the probes can ask exactly one question — would a new file
here be ignored? — and never the other one — is anything in there tracked right now?
Measured, not reasoned: a repository with .pinakes/{deep/op1.json,ledger.jsonl,index.db}
committed, then a correct .gitignore added, still lists all three in git ls-files, still
commits the edited transcript under git commit -a — and _ignored_by_git returns True.
The general shape, and it is the fourth instance in two days: a fix that removes a false positive can install a permanent blind spot, and nothing reports the trade. The opacity that made the detector right about patterns is exactly what makes it blind to the index. Neither property was written down, so the boundary was invisible to review.
MEDIUM — a correct .gitignore does not untrack an already-tracked file, and Pinakes says so
nowhere. grep -rniE "rm --cached|untrack|already[ -]tracked|already in the index" over
*.py *.md *.toml *.sh *.yaml returns only tools/template_drift_gate.py, tools/mutate.py
and a retrospective about the wheel. This matters because init.py already states the rule it
breaks — "the printed remedy must never instruct an action that would not change the verdict" —
and for a tracked-and-unignored KB, "add this line" flips the verdict to protected while changing
nothing about the exposure.
HIGH — a section heading is a status claim, and it produced the same collision twice in two days.
plans/20260731_1202-open-corrections.md carries an item under ## Live whose body says
CLOSED 20260824 00:35 twenty-six lines below its own **Decided.** line. On 20260824 a coder
session read it as live and was about to rebuild a landed increment. On 20260825 a second,
freshly-cleared coder session did the identical thing and offered to build it again. Both times
the save was a peer message; neither time was it a gate. grep '^## ' returns Live and stops.
MEDIUM — sed -i '' "s/X\bY/" on macOS silently matches nothing, and a success message computed
before the operation will not notice. Renumbering four colliding decision IDs, the loop printed
"7 line(s) rewritten" — which was grep's pre-count, not sed's result. BSD sed does
not implement \b. The file was unchanged and the log said otherwise. Reading the file back
afterwards is what caught it; perl -pi -e is what fixed it. A count taken before an operation
describes the input, never the outcome.
HIGH — the proposed fix for that blind spot failed in the blind spot's own direction, and two
sessions caught it only by running it. The first draft specified git ls-files -- .pinakes, with a
relative pathspec. Run from a subdirectory of a repository that is tracking three files under
.pinakes/, it returns rc=0 with zero rows — and a genuinely clean repository returns
rc=0 with zero rows as well. pnk init can be run from anywhere, so the two states are
reachable, identical at runtime, and the wrong one reads as safe. The absolute pathspec returns
three rows from the same directory. A check that answers clean because it was asked the wrong
question is exactly what the increment it corrects existed to remove, so the fix has to make the
pathspec structural — build it from the resolved root, and test it from a subdirectory, because
the failing case is invisible from the root.
MEDIUM — a remedy whose steps are right and whose order is wrong fails silently. In a repository
with no ignore line, git rm -r --cached .pinakes followed by an ordinary git add -A puts the file
straight back in the index (measured: one file, immediately). The ignore line has to come first.
The reverse order looks like it worked and reverts on the user's next add.
HIGH — knowing a hazard is not what avoids it; running the command bare is. The boundary table
above was first read through ... 2>&1 | head -3, which reported the outside-a-repository case as
rc=0 instead of 128 — the pipe's status, not git's. This repository had already recorded that
exact hazard, twice, and the session that walked into it had read the entry that morning, within the
hour. The entry is therefore restated in its stronger form: the rule does not fire on
recognition. The only thing that separates a real exit status from a plausible one is running the
gate bare and reading $?.
HIGH — the plan wrote a constraint from a measurement taken in the wrong place. § X1 required
the tracked-check's pathspec to be absolute and prescribed the test that would prove it: "run it
from a subdirectory — the case that fails is invisible from the root." The property is real. The
prescribed test cannot fail, because _ask_git passes cwd=root to subprocess.run
(init.py:94) and every call site passes root — so the process cwd never reaches git and a
relative .pinakes resolves against root regardless. The reproduction behind the constraint was a
shell fact, run with cwd=<subdir>, a state this code never produces. It was promoted to a
claim about a code path without anyone opening that code path's cwd.
The domain nobody examined was the instrument. Every other item here is a claim whose population went unstated; this one is a test whose ability to observe anything went unstated. The argument for the property was sound and stays sound — the absolute form survives as defence. What was wrong was the belief that a named test would pin it. A prescribed test is a claim about a failing test, and whoever prescribes it owes the same evidence as whoever writes it. The increment's own retrospective carries the mutation run that settled it, and the finding is recorded there rather than here.
MEDIUM — the planner produced the day's own lesson while writing the plan about it. I told a
coder that 36 unrowed test_fragments.py tests were a gap to fill. It counted the population
instead: 2023 tests defined, 1106 named in docs/VERIFICATION.md, 917 with no row — and the
document's own scope section says it maps promises to tests, naming six modules that predate the
table. A gap of 917 cannot all be holes. The coder declined, correctly, and cited a peer message is
coordination, never permission. Two honest counts of that same population differed by twelve
(1106/917 against 1094/929) purely because the matching rule differed, and neither number carries
its rule.
LOW — zsh does not word-split unquoted parameter expansions, so set -- $pair inside a
for loop passes one argument, not two. The anchor assertion caught it before any write. Worth
keeping only because the assertion is the reason: the guard that looked like ceremony was the thing
that turned a silent no-op into a stop.
X1 — building the tracked-KB check, and an instrument that could not fail (20260825 08:16)¶
HIGH — the specification asked for a test that cannot fail, and only writing it revealed that.
The plan bound the implementation to three constraints, the first being "the function takes the
resolved KB root and builds the absolute pathspec itself… its test must run it from a
subdirectory — the case that fails is invisible from the root." That constraint was written from
a real, reproduced measurement: git ls-files -- .pinakes run from a subdirectory exits 0 with no
rows against a repository tracking three files. Two sessions reproduced it independently.
It is still not reachable through this code, and the test it prescribes proves nothing.
_ask_git passes cwd=root to subprocess.run, so the process cwd never reaches git: a bare
.pinakes resolves against root and returns the identical verdict from a subdirectory, and with
a relative root. Measured rather than argued — with the pathspec mutated to ".pinakes", 68 of
the 69 tests in tests/test_init.py pass. A behavioural subdirectory test would have been green
against the exact form it existed to forbid.
So the constraint moved to where it can fail. The pathspec is built by its own function and the
test asserts the pathspec — :(literal) prefix, absolute, ending in .pinakes — which is the
one test the relative form kills. The absolute-and-literal form stays, because correctness of the
relative form depends entirely on _ask_git keeping cwd=root, a coupling no caller can see, and
because a KB root may legally contain * or [. It is defensive rather than a bug fix, and
the docstring says so instead of implying coverage it does not have.
The general form is worth more than the instance. "Pinned by test X" is a claim about a
failing test was already the rule here. This adds the case the rule does not obviously cover: a
constraint can name a real property, be derived from a real measurement, and still
prescribe an instrument that cannot observe it. The argument was sound; the domain it quantified
over — this call path, rather than the shell where the measurement was taken — was never
examined. Neither session checked it. It surfaced only because the implementer refused to write
test_…_from_a_subdirectory without first watching it go red.
A second gap the same discipline found. pinakes_tracked is a bool, so None and False
collapse into one field and no test through init can tell them apart — an implementation
returning False outside a repository (exit 128, fatal: on stderr) passed every test written.
The constraint that unknown must never read as clean is now asserted against _tracked_by_git
directly. A field's type can erase a distinction the specification depends on.
And the pipe hazard fired again, on the person who had just written it up. The full gate was
run as ./check.sh > log 2>&1; echo "EXIT=$?" | tee -a log; tail -20 log. The harness reported
exit code 0; the gate had exited 1, on two reportPrivateUsage errors. The $? was
captured correctly and then discarded by the commands after it — a gate is only a gate when its
exit status is what the next command reads, missed by someone who had recorded that sentence
twice the same evening. Recognition is not the mechanism. Writing the status to its own file,
and reading that file, is. Three instances of one class in one evening, each caught by running
something rather than by reading it — and this one caught only because the log was read after the
notification said the opposite.
MEDIUM — and the fourth instance of this increment's own shape: a CI watcher that was green for a
reason nobody had checked. Its exit condition was "all visible runs are complete". That is
unsound in general — with staggered run creation it exits before the remaining runs exist and
reports success for a run that was never created. Here it gave the right answer, because exactly one
run was ever going to be created: docs is path-filtered to docs/**, mkdocs.yml,
mkdocs_hooks.py, requirements-docs.txt and its own workflow, and this increment touched none of
them. But that fact was established after the watcher was armed, not before. The instrument was
sound by luck over a domain its author had not examined — written an hour after the same author
wrote that the instrument is the part nobody checks.
A second watcher, on the planner's side, failed the same way and was not saved by luck:
gh run list --commit <short-sha> returns empty, so an until [ ... = "completed" ] loop could
never terminate and would have polled in silence until timeout. --commit requires the full
sha. Silence from a watcher is indistinguishable from still running, which is why both were
rewritten to emit on every terminal non-success state rather than to wait for a success that may
never arrive.
S2 — A census is not a plan (20260825 18:55)¶
HIGH — two mutants survived 267 tests because every assertion counted actions instead of ordering
them. pairing.pair() returns a list of actions, and describe() — the helper the whole test file
asserts through — reduces it to a census by kind. When the same-path branch stops emitting a
SoftDelete, the vanished-path loop re-emits one further down the list, so the wrong plan and the
right plan have an identical census: same actions, same counts, different meaning. documents.path
is UNIQUE, so the order is the entire behaviour — an Adopt applied while the row it replaces is
still active raises IntegrityError instead of replacing it. One of the two survived the entire
suite. The generalisable form: when a function returns a sequence, a test that asserts its
contents has not asserted the sequence, and a helper that summarises is where that blindness
hides. The fix was a named property (retires_before_adopting) rather than a positional
assert kinds == [...], so it states what must hold rather than what happened to be produced.
A SURVIVED row is a claim about a pair, and here the wrong half was the selector. The inverted-guard mutant was killed by five tests — just not the one the battery named beside it. Re-running it against the whole suite rather than the selector is what showed that, and it took one command. The battery's own README already says this ("either half of which can be wrong"); what this adds is the cheap procedure: before believing a survivor, run the mutant against everything, not against its selector. A survivor that nothing kills and a survivor whose selector is misaddressed need different fixes, and they are indistinguishable from the battery's output alone.
MEDIUM — the first discriminator was wrong, and a test falsified it rather than a reviewer. The
doctor check was designed, reviewed and specified as "a retired row whose file and matching
sidecar are both still on disk". That rule misses the state S2 is named for. When a sidecar's id
changes at a path, the row that gets retired keeps its own old path, while the document that has
become unfindable is the one whose sidecar now claims that id somewhere else — so a path-based rule
reports the wrong document and misses the real one. What found it was writing a test for an adjacent
case (a path reused by a different document) and watching it fail for a reason I had not predicted.
The rule is now keyed on the retired id. Worth stating because the wrong rule had already
survived a careful adversarial review: a specification written from the mechanism can be wrong in
the same direction as the code, and only a case neither party considered separates them.
MEDIUM — running corrected a confident inference, and the correction changed the build order. From
the pure function I inferred that a rename chain silently re-mints a published id — a Mint for a
document whose sidecar already carried one, which would breach ULID permanence. Running it showed
the crash preempts the mint: on main the first adoption raises before the Mint is ever
reached. The defect is latent rather than shipped. That is not a smaller finding, it is a different
one, and it carries an ordering constraint nobody had written down: fixing the rename collision
without this fix first would unmask the re-mint. The inference was sound and the conclusion was
wrong, which is this repository's most-recorded failure shape and was reproduced here in the space
of ten minutes.
LOW — a condition that cannot be false is worse than no condition. The first draft of the guard
read claimed is not None and claimed.document_path != path. The second term can never be false
where it is evaluated: the sidecar beside that path is the one that disagrees, so the sidecar
claiming the row's id is necessarily a different one. It would have shipped as an unkillable mutant
— a line no test can pin, which reads as care and provides none.
MEDIUM — I measured the check as free, and it is not. It costs 12–17%. The first measurement
said pnk doctor went 0.822s → 0.818s over 2000 documents and I wrote "free, within noise" into a
fragment. An adversarial reviewer measured +12% on the same corpus, which sent me back to it. They
were right. Re-measured alternating branch and main against one KB rather than running each side
once in its own worktree: main 0.7099s / 0.6700s, branch 0.7959s / 0.7823s — +12.1% and +16.8% —
and walk_document_paths alone is 49 ms over 1995 documents. The added work is real, it is the
glob, and it is paid only when the KB has retired rows (otherwise the check returns before walking).
The method is the finding. Two sequential measurements taken in two worktrees cannot resolve a 10% difference on a machine that drifts by more than that between them — my "before" run happened to land slow and my "after" fast, and the difference I was trying to detect was smaller than the drift I did not control for. Interleaving costs nothing and would have shown it immediately. A/B by alternation, never A-then-B, whenever the effect is smaller than the noise you have not measured.
What survives is the comparison that actually mattered: the attempt this replaces measured 2.25x
(0.746s → 1.682s) and this is about 1.15x, because the expensive question was never the check —
it was a SHA-256 over the whole corpus and a second ruamel parse of every sidecar _sidecars()
had already parsed. Reusing that parse and asking the walk for paths alone is what the ratio buys.
"Cheaper by a factor of eight" was the honest claim; "free" was not, and nobody would have caught it
if a reviewer had not run the number I had already published.
And the exit code is still not a validity signal, demonstrated while verifying this. Running the
real pnk doctor against a healthy 2000-document scratch KB exits 1 — for embedding and
reranker, because the fake backend is registered inside pytest and not in a plain CLI process.
Two sessions built measurement harnesses on that exit code in one day; this is a third sighting
inside an hour of deliberately watching for it. Assert on the named check row, never on the exit
code.
HIGH — the fix introduced the exact shape it was removing, and only an adversarial pass saw it.
Guarding the branch where a sidecar disagrees with the row left the ordinary branch alone, so a
sidecar moved from one document onto another produced RefreshMetadata(X, a.md) beside
Adopt(X, b.md): the same-path loop trusted the index row because nothing beside a.md
contradicted it, and the adoption loop followed the sidecar that had walked away. One id, two
paths, one plan. Measured on both sides: origin/main raises IntegrityError and keeps both rows;
this branch exited 0, moved the row to b.md, and left a.md on disk with no row at all —
indexed yesterday, unfindable today, nothing recorded. A loud failure turned into a silent one is
the precise regression that condemned the previous attempt at this fix, and this increment
reproduced it while carrying a commit message about not doing so. Two things made it findable: an
independent reviewer that ran origin/main as a control rather than reasoning about it, and a
UNIQUE constraint that had been the only thing noticing — by crashing. The lesson is not "guard
the other branch". It is that a guard written for the case you are thinking about is not a
property; the repair is places_each_id_once(), asserted over every shape the file exercises,
because a helper asserted case by case is a helper nobody runs on the case they did not think of.
MEDIUM — a property helper that did not implement its own docstring. retires_before_adopting()
was written to assert "no Adopt may land on a path this plan retires later", and built its
index of retirements with reversed(list(enumerate(...))) — so for a path retired twice it kept the
earliest position, and a plan that retires a path, adopts onto it, then retires it again passed.
It was doing real work (it is the only thing that kills two of the battery's mutants) and it was
still wrong about the sentence above it. A named property is worth more than an inline assertion
and is also a second place to be wrong; the reviewer read the implementation rather than the name,
which is the only way that class is ever caught.
MEDIUM — the count I published was scoped and I called it complete. This file said a mutant
"survived the entire suite" and the battery said "the whole suite of 267 tests". 267 was
pytest tests/test_pairing.py tests/test_sync.py tests/test_doctor.py — the three files covering
the code — while the suite is 2167. Caught when a planner asked to publish the number in a document
whose whole subject is not publishing counts nobody has run. Re-measured properly, at the exact
commit where the survival was observed, with the mutant applied and the full suite run: 2165
passed, 0 failed. The claim was true and the denominator was invented, which is the same defect
one level up from the one the paragraph was describing. State the population, not just the
number — and when a scoped run is what you have, name the scope.
HIGH — I reported a defect as open after my own later commits had fixed it, and a planner recorded
it as scheduled work. S17 (a rename that frees a path a new document then takes) was measured
early, against origin/main and against my branch as it then stood; both failed, so I filed it as
pre-existing and unfixed. It was pre-existing. But the moved-sidecar guard that came out of the
second adversarial pass — four commits later — fixes it as a side effect, because S17's mechanism is
the same one id at two paths shape. Re-measured with a control: at 03e6f86 the walk records
SidecarError: … appeared after the walk had already read this directory and the renamed document
is never indexed; at 3876b57 it reports 1 indexed, 1 renamed and the document is active.
The error is not the measurement, it is the report: a finding taken against a moving branch has a
sha, and mine did not carry one. It was true when taken and false four commits later, and nothing
in the report let either of us see that. It had already been written into a plan as a two-part fix,
which is one message from a fresh session rebuilding something that works — the failure this
repository's own CLAUDE.md records happening twice in two days. Stamp every cross-session
finding with the commit it was measured at, and re-measure before anyone schedules it.
And the repair for the claim itself is a test, not a corrected sentence. "S17 is fixed" written in
a plan is a fact that decays silently; test_a_rename_that_frees_a_path_a_new_document_then_takes
is the same fact that goes red if it stops being true. Confirmed red at 03e6f86 before being
believed.
LOW — and the same disease in a docstring I had just written. _retired_documents claimed the
pre-commit hook's sidecar "cannot trip it: nothing the hook does retires anything", and the
changelog fragment repeated it. A reviewer measured otherwise: at a reused path — a new document
at a name some earlier document held — sync --sidecars-only does reach rule (2). The report there
is true (the document has no row and pnk search cannot see it) and the shipped hook pair never
leaves that state, since post-commit indexes inside the same commit. So the check is right and the
sentence was wrong, which is the more embarrassing half: an absolute claim written to explain why
a design is safe, in the same paragraph that had already earned the right to a narrower one.
Taking eight decisions into the documents — what the writing found (20260825 18:55)¶
The decisions were taken by the user at 20260825 18:16. This is what surfaced while writing them into the files, and every item is something the deciding pass did not know.
HIGH — a correction about false precision was itself a timezone error, and it nearly propagated.
The decision record listed three defects in docs/VERIFICATION.md's scope sentence. Two were
already resolved before the pass began: the 923→890 row count was corrected earlier the same day,
and the "impossible" provenance stamp is not impossible — c23359f is
2026-08-25T13:42:55+01:00, i.e. 12:42:55 UTC, so the 12:49 UTC measurement is seven minutes
after it. An adversary read a local timestamp as UTC. Had the planner trusted the list rather
than checking it, it would have "fixed" a correct stamp into a wrong one — a repair that
manufactures the defect it repairs. Checked with git show -s --format=%ad --date=iso-local, not
by reading.
HIGH — the two numbers in one sentence rotted the same way, and the earlier correction pass fixed only one of them. The same sentence carried "890 rows" (corrected) and "62 of the 67 test modules" (left). The module figure was wrong on both halves: there are 74 modules and 63 carry a row. A sentence with two numbers of one class is a sentence where correcting one and stopping is the likely failure — the reader's eye treats the sentence as handled.
HIGH — the obvious way to count that sentence reports 74 of 74 and finds nothing. Counting
"modules named in the document" by testing whether each filename appears in the text is wrong here,
because the preamble itself lists the eleven unnamed modules by name. The measurement instrument
is contaminated by the prose it measures. The gate's own REFERENCE regex — which matches only a
tests/x.py::y reference — gives 63. This was hit live, mid-pass, and the wrong number was on
screen before the right one.
HIGH — the bounded audit found a cluster, not an instance, and the cause was structural. D-34
was licensed on one unrowed promise found by sampling. Auditing a single module found 14 of
tests/test_serve.py's 31 tests unrowed, two of them security boundaries: the MCP path-refusal
(../../etc/passwd) and the labelling of retrieved text as evidence rather than instruction.
The cause was not neglect — the server's rows lived under the links release and page
citations, and no section owned the server boundary. A missing section is invisible to every
gate and to every reader who checks whether a row exists. Fixed by adding the section, not by
adding rows to the wrong ones.
MEDIUM — a tripwire whose condition was already met on the day it was armed. The
staged-channel-gates clause said "two entries exist and are deliberate… if a third appears, that is
the drift this rule exists to catch." docs/DESIGN.md had carried a third entry of the same
shape since five days before the clause was written. It reported drift that was not drift, and
cost two separate readers a full pass. A rule that counts instances rots; a rule that names a
class does not. It now forbids a class — no plan, no increment, no numbered item, no version
number — and says index, routing and naming entries are permitted and uncounted.
MEDIUM — a count went stale eleven lines from a row that already carried the right one.
docs/README.md said the sweep held "fifteen confirmed defects" in its prose and "Sixteen
defects" in the table below it. Both were written the same day. Proximity is not consistency,
and the same file is where a routing table already carries the instruction "this row deliberately
states no count" — a remedy that existed one row away and was not applied.
HIGH, and the worst of these — a gate was cited as evidence for a property it cannot see. A
blockquote annotating a table row was written inside the table, which renders it as a table cell.
The repair moved it after the last row with no blank line, so Python-Markdown's tables
extension swallowed it as one more <tr> — a cell whose text begins with a literal >. mkdocs
build --strict exits 0 on the broken form and on the fixed one alike, so "make docs EXIT=0",
offered in a commit message as proof the repair worked, certified nothing about the repair. The
generalisation is the same one this project already applies to tests and to mutation batteries, and
it had not been carried across to gates: make the gate red on purpose first. If it cannot fail on
the broken form, it is not evidence for the fixed one. It is also the same failure as
check.sh | tail reporting tail's exit status — a green signal that is not a function of the
property anyone cares about — and the two were hit by two different sessions within an hour.
LOW, and it cuts against this pass — the writing changed the number the writing states. The scope sentence records the table's row count. Adding fourteen rows moved it from 890 to 904, so a count copied from the parent commit would have been falsified by the edit that copied it. Stated in the file rather than silently re-measured, because it is the third time in one day a change here invalidated a pointer to itself.
Reconciling the queues — two registers of one fact, and an owner with nowhere to queue (20260826 06:32)¶
The increment was meant to close a scheduling gap CLAUDE.md names in bold. It found the gap was
the smaller half.
HIGH — a file that holds two registers of the same facts will diverge, and no gate can see it.
plans/20260825_1252-plans-sweep-findings.md carries a 27-row Actionable table with Status,
Blocked-on and Owner columns, and a § Open questions list of thirteen bullets. They describe the
same decisions. The 20260825 18:16–18:41 pass updated the bullets, inline, carefully — and never
touched the table. Twelve of the 27 rows stopped describing the tree — eleven of them stating
LIVE (eight) or UNCLEAR (three) for work that was built, answered, declined, deferred or ruled
that evening. This is the repository's own recorded failure — a ## heading is a status claim and
nothing gates it — moved one level in, into a table cell, where reading the item's body does not
help because the body is the cell. The fix is not to maintain both. It is to say which one wins:
a dated snapshot carries a disposition, a ## Build order carries the queue, and where they
disagree the build order is right by construction.
HIGH — an owner is not a schedule, and the difference is measured in weeks. Three decided items
had an owner and no queue position anywhere: the _toml.py unknown-key remedy, the paid
re-extraction loop's trigger, and the G5 gate re-run — which sat that way for 21 days while
every sweep that read headings passed over it. The seam is structural: an item whose owning plan has
no ## Build order has nowhere to be queued, and docs/README.md routes to files, not to work.
Parking them in one named section beat both alternatives — a fourth register would repeat the defect
above, and a build order per plan makes a coder read four files to learn what is next, which is how
the seam formed.
HIGH — the top of the coder's queue was a claim nobody had re-checked, and checking it took nine
minutes. CLAUDE.md said S16 was "still live, reproduced on main 20260826". Nothing in the
sweep plan supported that date — the only run on record is 32442db, 20260825 18:18, which is
before S2's fix landed at 04:06 the next morning. And S2's fix is known to have cured S17 as a
side effect, so "does it also cure S16?" was a live question that no document had asked. Reproduced
end to end against a src/ tree byte-identical to origin/main: it is still live, all three
failures intact. The finding is not that the claim was false — it happened to be true — but that
nobody could have known, and it was written as though someone did. A defect recorded against a
moving tree needs its sha; S17 was recorded the same way and had already been fixed.
MEDIUM — reading names instead of targets put a false claim in a file that exists to state a
denominator honestly. tools/batteries/README.md named src/pinakes/cli.py among "the two
highest-churn modules … [that] still have none". src-pinakes-init.toml mutates it twice. The
same error hides more: tools-mcp_handshake_gate.toml reaches seven files including Makefile,
check.sh, pyproject.toml and both CI workflows. A battery's name is not its coverage, and
the question has a one-line answer nobody was asking —
grep -h 'file = ' tools/batteries/*.toml | sort -u. Its own gate could not catch it:
tests/test_batteries.py forces a battery whose stem does not begin tools- to be named in the
README, and checks nothing about what the batteries reach.
MEDIUM — I invented a duration inside a pass about unmeasured claims. I wrote "fourteen hours"
for how long the table said S2 · LIVE, four times across four files, and never computed it.
That count is not re-derivable from git and this sentence is the only record of it — the error
was corrected before the first commit, so history contains this description and not one instance.
Stated here rather than left to look like a measurement: it is a report of a working tree nobody
else can inspect, which is the weakest kind of claim this repository accepts and it is accepted only
because a retrospective has no other way to describe what it caught. S2
landed at 04:06 and the pass ran at 06:19: two hours. The eleven decision rows were the
twelve-hour ones. Two different numbers had been collapsed into one invented figure that flattered
the finding. Caught only because the timestamps had to be re-derived for an unrelated reason.
MEDIUM — and the reason they had to be re-derived: --date=format-local where the rule says UTC.
Local here is UTC+1, so 3876b57 went into three files as 05:06 when it is 04:06 UTC. The
retrospective fragment written twelve hours earlier records an adversary making the same
substitution in the other direction, and says it "would have 'fixed' a correct stamp into a wrong
one". Reading that fragment did not prevent repeating it, which is worth more than the fix: the
guard has to be in the command, not in the memory of a lesson. git log takes --date=format-local
and TZ=UTC git log gives the rule's answer; nothing warns you which you typed.
LOW — main moved twice during the pass, and once it falsified a row as it was being written.
A branch recorded as unowned — its author's session ended, and a direct message to the only other
candidate failed HTTP 409 — landed while that row was being drafted. A peer said so; the claim was
verified against git ls-remote rather than relayed, and the row was deleted before it was
committed. The rule that saved it is the cheap one: verify at the moment of landing, not at the
start.
make release-check — a gate that could not fail, and what that says about the others (20260826 06:38)¶
HIGH — the target had a help string, a docs/RELEASING.md step, a CLAUDE.md rule and a
docs/STATUS.md sentence, and no comparison anywhere in its recipe. Four documents described a
check; the recipe printed three lines and exited 0. Nothing was wrong with any single document —
each was written by someone who had read the others. The failure is that a target's documentation
and a target's exit status are separate artifacts, and only one of them is executable. The
generalisable form: a check named in four places and implemented in none is indistinguishable, from
every document, from a check that works. It survived from the target's creation to 20260826, and
what found it was neither a gate nor a reader of the Makefile — it was a planner building an
argument that rested on the phrase "the tree make release-check was run against" and going to
read what that meant.
The remedy is not "test the gate" — it is make help and the recipe pinned in one test. The
## string is what a release operator reads and it is not an argument to the recipe, so the two
can disagree silently forever. test_the_release_check_help_string_and_its_recipe_are_pinned_together
holds them together. Two of this increment's mutants are the same idea one layer down: commenting
the recipe out (make hands \t#… to /bin/sh, which exits 0) and a single leading - (make
discards that line's exit status). Both restore "the target cannot fail" in one character, and
both now die.
MEDIUM — a KILLED row is not self-validating, and this is the mirror of the survivor rule the
batteries README already states. The mutant for "an annotated tag with an empty message passes"
was written as if not …: → if False:. It reported KILLED. It was killed by an IndexError
traceback out of splitlines()[0], not by the exit code — because removing that branch does not
make the empty-message case pass, it makes the tool crash. So the row was a true statement about
the wrong property: the tests notice a broken tool, not the tests notice an unannotated tag being
waved through. Rewritten as an early return 0, it dies on assert 0 == 1. Read what killed a
mutant, not that something did — mutate.py prints the assertion, and it is the only place the
distinction is visible.
MEDIUM — the increment's own test of its own gate had the escape hatch the gate exists to
remove. Nine of the eleven tests supply --repo and --expect-version, so the defaults are a
region no fixture reaches; the one test covering them read
assert str(ROOT) in output or result.returncode == 0, and on the green branch the or carried it
and it could not fail. An unfailable assertion inside the increment whose entire subject is an
unfailable check. A test seam does not only hide the real path — it concentrates the temptation
to write a weak assertion there, because that is where a strong one is awkward. The repair was
two assertions that can fail plus two mutants (REPO repointed, the default version hard-coded to
0.0.0) that are invisible to every other test in the file.
LOW, and named rather than closed: the gate never asks whether HEAD is on the remote's default
branch. A tag on an unpushed commit passes all four legs, and pushing it hands the publishing
workflow bytes that are on no branch. Every offline form of the check is wrong — a remote-tracking
ref is as stale as the last fetch, and the remote's tip may be an object this clone does not have —
and the correct form has to fetch, which makes a release gate mutate local state and go red for
being behind, a state that is not a publish hazard. Left to the procedure, which lands and pushes
before it tags. The reasoning is in the tool's docstring, not only here, because the next person
to notice the gap will be reading the tool.
A citation is a measurement, again, and this time it was caught inside the same increment. The
docstring's gap paragraph first read "steps 4 and 5 land and push before step 6 creates the tag" —
citing a numbering that only exists in a diff still sitting with the planner. Rewritten number-free.
The same pass kept docs/RELEASING.md's steps 6 and 7 unrenumbered for the same reason: five
live citations name "docs/RELEASING.md step 8" — in CHANGELOG.md, docs/STATUS.md,
docs/ROADMAP.md, .github/workflows/release.yml and tests/test_check_script.py — and pushing
that step to 9 would leave every one off by one, silently, in the exact class this repository has
now recorded five passes of.
Measuring the review loop — every arithmetic claim in the report had an error (20260826 06:50)¶
A session measured what this project's adversarial review costs, from the agent transcripts on disk, and proposed eight ways to make it cheaper. An adversarial fan-out over those proposals returned 52 findings. Six of the six arithmetic claims that were re-derived turned out to be wrong, in a report whose whole purpose was to be quantitative. The mechanism it described survived; almost none of its numbers did. What follows is why each one broke, because the causes are not specific to this measurement.
HIGH — a number in a docstring is a claim with no gate on it, and the fix that invalidates it will
not say so. tools/review_pass_gate.py shipped citing "613 review agents, 87% leave some
artifact, 58% of 6,015 targets are relative paths". During that same increment a filter was added
rejecting shell fragments a redirect-scan had reported as files — 0, c, 15,} — because a real
run showed them. Adding it changed the denominator of the measurement quoted three paragraphs
further down the same file, and nothing re-ran it: 49.2% of those 6,015 "targets" were never files.
The true figures are 41% leaving any artifact and 59% leaving none. ./check.sh, pyright, ruff
and sixteen tests were green throughout. Lesson: prose in a source file is unversioned evidence.
When a change alters what a measurement counts, the measurement is stale even though the diff never
touches it — and no gate in this repo can see that.
HIGH — a keyword classifier over agent briefs counts the repository's own instructions. The
population was built by matching review over each subagent's task. CLAUDE.md tells every
implementer to "adversarially review" its work, so coder agents matched and were counted as
reviewers — and coder agents write far more files, and cost far more per turn (median ~173,500
tokens/turn against a reviewer's ~61,700), so they distorted every derived ratio. The replacement
classifies on role and was hand-audited over a 30-run random sample: 14 of 14 correct, recall
imperfect, so it is a lower bound rather than a point estimate. Lesson: when the corpus is agent
transcripts, the vocabulary of the task and the vocabulary of the instructions are the same
vocabulary. State a classifier's measured error rate, or do not state a population.
HIGH — the currency was never declared, and the two currencies disagree by 25x. "87% of review spend is re-transmission, 13% is the findings" is a weighted ratio. In raw tokens — the unit every other figure in the same report used — it is 99.5% re-transmission and 0.5% output. Both are true; quoting one beside totals stated in the other puts two denominators in one paragraph. Lesson: a ratio over tokens is meaningless until the weighting is written next to it.
MEDIUM — a pooled ratio is not a typical one. "101,256 tokens per turn" was Σtokens ÷ Σturns across the corpus. Corrected and de-contaminated it is 91,617 pooled, against a per-agent median of 61,688 — the pooled figure sits near the 90th percentile of individual agents, so it described almost none of them. Lesson: pooling answers "what did the fleet cost"; sizing a per-agent budget needs the per-agent distribution, and the two differ by 50% here.
MEDIUM — six merges from one afternoon are not "recent increments". The report argued that review
was disproportionate to the work, citing the last four merges at 7, 37, 62 and 232 changed lines.
Those were one burst of documentation and fragment merges. Re-sampled, the surrounding increments run
70 to 1,211 lines. An entire proposal — scale the review down for small increments — rested on that
sample and does not survive it. Lesson: git log --merges -n returns the most recent n, not a
sample of the distribution, and a project that lands in bursts makes those two very different.
And the finding that made the rest recoverable: the fan-out that raised all of this lost every one
of its six refuters to a usage limit and returned {"confirmed": []}. Written the way the tool's
own documentation recommends — collecting results and filtering out the empty ones — that is
indistinguishable from an adversary that examined the work and approved it. The findings survived
only because the harness counted launched agents against returned ones and refused. Each was then
confirmed by re-deriving it rather than by reading it again. Lesson: an adversarial pass that
returns nothing is making a claim about itself first and about the work second, and re-reading a
number never falsifies it — recomputing it does.
And the finding the report should have led with: the retrospective loop IS the spend, and its yield halves while its price does not. The report's own share figures were computed from a keyword classifier and were wrong twice. Recomputed the plain way — list every subagent run's task title with its token total, then let the clusters fall out — 899 runs and 5.13 billion raw tokens divide like this: adversarial review of an increment, plus the refuters serving it, is 44.0%; building and fixing increments is 18.1%; plan work 7.8%; decision analysis 5.9%; sweeps 5.7%; corpus and eval authoring 1.5%. A further 16.7% resisted clustering and is named here rather than distributed, so 44% is a floor. Review is 2.4x the next largest category, and the nine single most expensive runs in the project are all build work, which is the other thing the title listing makes obvious and no proposal in the report addressed.
The decay inside that loop is recorded in the repo's own agent briefs, because each pass is told what came before: passes 1–4 over one increment found 30, 22, 13 and 6 issues. Median cost per pass, over the runs that state their number: 7.5M raw tokens at pass 2, 12.5M at pass 3, 12.9M at pass 5 — flat to rising. Yield halves, price does not, so the marginal cost of a finding roughly doubles every pass. The caveat that keeps this honest: only 43 runs state which pass they are, and later passes announce themselves ("the FIFTH adversarial review pass") while a first pass usually just describes the task — so the split of spend by pass number is biased and is not quoted. The decay curve is quoted verbatim from the briefs.
Lesson: the loop's cost is not the depth of any one pass, it is that every pass pays full price for context the previous ones already established. Each pass is a fresh agent, told the history but handed none of the evidence, so it re-derives the map before it can look for anything new — at a measured ~102,000 tokens per turn, where 99.5% of those tokens are re-transmitted context and 0.5% are the findings. That is the shape to attack: not fewer passes and not cheaper models, both of which trade away what later passes are good at, but a later pass that starts from what the earlier ones established instead of from zero.
And the method lesson, which is the one that generalises: the clusters above came from listing every task title against its token total and reading the list. Three successive regex classifiers over agent prompts had each produced a different, confidently wrong answer. Sort the units of work by cost and read the labels before writing a classifier over them.
D-35 layer 2 — the half a gate cannot see, and a gate I ran wrong (20260826 06:59)¶
HIGH — I ran uv run ruff check . | tail -1 between edits, and it reported tail's exit
status. A failing checker read as a pass, and it hid a real E501 for two edits. This is not a
new lesson anywhere: check.sh exists because of exactly this, says so in its own header
comment, and CLAUDE.md carries it as a standing rule — a gate is only a gate when its exit
status is what the next command reads. Nothing false was ever claimed, because ./check.sh runs
its gates bare and is what ran before each commit. The generalisable part is where the defect
lived: not in the project's gates, which are correct, but in the ad-hoc command I typed to check my
work between them. A rule written about check.sh was read as being about check.sh. Piping any
checker into head, tail, grep or tee to read its output is the same mistake wherever it
happens, and the safe habit is cmd > log 2>&1; echo $? — the exit status first, the output after.
HIGH — the property worth gating was the one no existing check could ever have failed on. D-35's
own reasoning is the durable part: an index-based rule can require the marker when a version is
absent from PyPI, and can never require its removal once the version is present. Removal is
green by construction — in the header gate (its SHAPE stops at the closing **), in the
release-order gate (no sequence reads line 3's tail), and in the index rule itself. When choosing
what to build, "which direction of this property is currently unfalsifiable?" locates the gap
faster than "what is broken?" — the broken direction usually already has a check; the
unfalsifiable one has never had a chance to fail.
MEDIUM — the mutant that proves it is the one the real tree cannot show. PUBLISHED_ROW names
which of release_order_gate.py's two docs/STATUS.md sequences carries R. Repoint it at the
other one — the Published on PyPI prose, forty lines away — and: the real tree stays green,
because both sequences have the same newest entry today; the test asserting the sequence resolves
stays green, because the prose sequence exists; and every other test stays green. Only a fixture
whose prose is absent kills it. Reading the wrong one of those two is a recorded failure in this
repository — it is how the row drifted four releases while the gate reported every one of them
present, in the sequence next door. A mutant whose kill requires a fixture is exactly the mutant
worth committing, and the reasoning beside it is the part that is not re-derivable.
MEDIUM — the gate dictated remedy text it could not hold true. Its failure message told the
operator to write "landed on main, NOT tagged and NOT on PyPI". NOT tagged is a claim about
git that goes false at the tag, while the version is still unpublished and the Published
versions row may legitimately still lag — so the gate would have been green over a marker it had
itself dictated and which had become half-false. The qualifier now names the index only. The rule:
a check may only prescribe text whose truth it can keep checking; anything else it suggests is
a claim it has quietly delegated to a human and stopped watching.
LOW, stated before the build rather than discovered after it — layer 2's green is narrower than it
looks. docs/RELEASING.md permits the Published versions row to lag a release, because an entry
is held back until it is verified from the index. So after a successful publish line3 > R still
holds and layer 2 stays green over a marker that has become false. It enforces the marker's removal
at the moment the row is updated, not at the moment of publication; the publication window is
layer 3's, which is soft and not built. This is written into the tool's docstring, not only here,
because the next person to over-read this gate's green will be reading the tool.
One code change falsified six documents, and the owner of the code could not fix any of them (20260826 07:02)¶
make release-check became a real gate in 674eda6. The moment it landed, six documents were
false — and none of them was in the coder's file set.
| Document | What it said |
|---|---|
docs/README.md |
"make release-check runs no gate at all" |
docs/ROADMAP.md ×3 |
the same claim, at :124, :185, :2274 |
plans/20260731_1202-open-corrections.md |
the item itself, 🛑 LIVE |
docs/STATUS.md, CLAUDE.md |
"before the tag, never after" — now before the push |
MEDIUM — two rules this repository holds simultaneously cannot both be satisfied, and nobody had said so. Keep docs in sync with code — in the same change is a standing rule and calls stale docs a severe defect. Documentation has one owner says no other agent edits a document; it proposes. For any coder change that falsifies a document, the same-change rule is unsatisfiable by construction — the person who knows the change cannot write the document, and the person who owns the document is a separate session. There is always a window. Here it was about twenty minutes and nothing published inside it, but that is scheduling luck, not a property of the mechanism.
The tension resolves in favour of ownership, and the cost should be named rather than absorbed. The mitigations that actually worked, both of them cheap: the coder enumerated the falsified documents in the same message that announced the landing — five of the six, each with a line number, before being asked — and the planner ran the sweep as the next increment rather than batching it. Neither is written down anywhere as the procedure. What makes the window survivable is the handover listing the damage, not the ownership rule being relaxed.
LOW — the counted anchor did not bite, and it was worth checking rather than assuming.
docs/RELEASING.md warns that Part 5's Open corrections heading carries its own item count in its
anchor, so closing an item breaks every in-page link. Checked: the heading is a bare
## Open corrections at ROADMAP.md:2255 and the counts live in prose at :102, :122 and :186.
Corrected 20260830: every one of those numbers was right at c111645^ and wrong the instant
c111645 landed — the commit that added this fragment shifted ROADMAP.md by a line. :124 was
never a count, so the third is :122, not :124 plus the shift.
The warning describes a hazard that is not present in the current text — true when written, and
now a trap for whoever believes it and works around a problem that is not there.
LOW — five citations were the reason not to renumber, and they were verified rather than
relayed. The coder's diff deliberately kept steps 6–8 numbered as they were, because five live
citations name "docs/RELEASING.md step 8" — CHANGELOG.md, docs/STATUS.md, docs/ROADMAP.md,
.github/workflows/release.yml, tests/test_check_script.py. All five were grepped before the diff
was accepted. Renumbering is the invisible half of a documentation edit: nothing goes red, and
every citation is off by one.
S19, measured twice independently — and the row that disagreed is the useful one (20260826 07:20)¶
S19 said pair() emits an inapplicable order for renames that are not cycles. Until today that was
an argument from reading pairing.py, and the plan said so in a provenance paragraph. Both
halves have now been run: the coder reproduced it end to end, and the planner re-drove pair() from
a separately written probe rather than checking the coder's output.
MEDIUM — three rows matched exactly, and the fourth differed for a reason worth keeping. On
cycle, non-cycle-two and chain-of-three the two probes emitted identical action sequences. On
rename onto a free name one probe reported RefreshMetadata and the other Skip — because one
construction changed the untouched document's sidecar hash and the other did not. The row is green
either way and the disagreement changes nothing, but recording why two measurements of "the same
thing" differ is what makes the three that agreed worth anything. A silent reconciliation would have
left a reader unable to tell agreement from coincidence.
MEDIUM — the control was designed to kill the plausible-but-wrong fix, not to demonstrate the bug. The obvious pinning case is the two-file shift. The coder argued for a chain of three instead: its valid order is strictly reverse, so no accident of path-sorting can produce it, and a fix that merely reorders two adjacent actions passes the two-file case while failing this one. That is the difference between a test that proves the bug existed and a test that constrains the fix.
LOW — and it converted a build-order row from under-scoped to scoped. Before the measurement, "make swaps work" was a defensible reading of S16. After it, the classes are distinct and measured: ordering the applicable plans fixes the whole chain class; cycles need a temporary path and a separate mechanism. The plan now says so, and asks that the cycle case be shown still failing in the commit that fixes the chain — otherwise "swaps still crash" becomes "swaps are fixed" in the next summary, which is precisely how S17 was recorded as open after being cured.
The marker was 0-for-2 because the procedure never asked for it (20260826 07:27)¶
MEDIUM — a convention with a 0-for-2 record was blamed on the people following it, and the
procedure had never contained it. docs/STATUS.md's hold marker had been written twice and missed
twice, and that record is what justified building a gate (D-35 layer 2). It is a good gate. But
docs/RELEASING.md — the document a release operator actually follows, whose sweep table exists
precisely to name every place a release goes stale — never mentioned the marker at all. A release
cut by following it verbatim produces the false line, every time, correctly. Before building a gate
because a convention keeps being missed, check that the convention is written where the person doing
the work would read it. Both were worth doing here; only one of them was diagnosed.
MEDIUM — the live marker carried a claim its own gate had deliberately rejected. Line 3 read
"landed on main, NOT tagged and NOT on PyPI". The coder had explicitly cut NOT tagged from the
gate's suggested text, because it is a claim about git that goes false at git tag while the
version is still unpublished — the line would be half-wrong for the whole tag-to-publish interval,
with the gate green over it (HOLD requires only ⏸ and a bold span naming the published version).
The reasoning had been recorded in the gate's docstring and the document it governs was never
brought into line. A decision written down next to the code is not a decision applied to the data.
LOW, and it is the third instance today of one trap. Driving the gate red on purpose, the first
measurement was uv run python tools/status_header_gate.py 2>&1 | grep -v warning; echo $? — which
reports grep's status, not the gate's, and printed 0 for a run that had just failed. The
coder hit the identical shape with | tail -1 an hour earlier and wrote it up as "a rule written
about check.sh got read as being about check.sh". The rule is not about a file; it is about
whatever command's exit status you are actually reading — and an ad-hoc pipe typed to inspect a
result is exactly where it is forgotten, because it does not look like a gate. Re-measured bare:
exit 1 without the marker, exit 0 with it.
The cycle's price, found by building the fix and backing it out (20260826 07:33)¶
MEDIUM — the obvious remedy for a defect had a cost nobody had counted, and counting it required
writing the code and throwing it away. S16's cycle class (a ↔ b) looks like it wants a clean
refusal: pair() raises before anything is applied instead of emitting a plan that crashes halfway.
The coder built exactly that, it worked, and backed it out — it breaks five committed tests, and
three of them are guards that exist only while a cycle still produces a plan, including
test_a_rename_cycle_that_fails_halfway_never_destroys_a_live_row, which pins the silent-loss
shape S2 exists to prevent and can only observe it by watching a plan be applied and fail.
So the cycle's remedy is not "add a temporary path". It is "add a temporary path and replace three of S2's guards". A build-order row reading make cycles work would have under-scoped it exactly as make swaps work under-scoped the chain — the same error, one class along, and it would have been discovered mid-increment by someone with a fix already written.
The judgement worth keeping is the refusal to land it. Refusing a cycle cleanly is a behaviour decision costing S2 coverage, not an implementation detail of an ordering fix, so it did not belong in that increment however well it worked. Backing out working code because it answers a question nobody asked is harder than shipping it, and it is what the never assume what the plans have not decided rule actually costs when it bites.
LOW — and one of the three guards contradicts itself in a way that will block precisely the person
who settles the cycle. Its docstring: "The sync's own outcome is deliberately not asserted… what
this test pins is that no live document loses its row, which must hold however that defect is
settled." Its body: contextlib.suppress(sqlite3.IntegrityError), which pins the exception
type. Settle the cycle by raising anything else and the test fails, with a docstring promising it
would not. A test's intent and its implementation can disagree, and the docstring is the half that
gets read — nothing checks the other one against it.
Every review pass rebuilds the map, and the expensive ones pay most for it (20260826 07:40)¶
Measuring where adversarial review spends its tokens, and building the tool that carries the answer forward, produced five things worth keeping. Three are about this repository's review loop; two are about measurement itself, and those are the ones that generalise.
The cost is re-derivation, and it scales with the pass rather than away from it. Over the 910 subagent transcripts here, 35.6% of a later review pass's raw tokens go to turns whose only file access was a file an earlier pass over the same increment had already opened — against 3.0% for turns that opened something new. 96% of what the median later pass opens was already opened by an earlier one. And the share rises with how expensive the pass is: 40.1% over the 69 passes costing more than 5M raw tokens. The reason is arithmetic rather than psychology — a file read at turn 6 of a 90-turn pass is re-transmitted by all 84 turns after it, so the earlier and longer the pass, the more a redundant read costs. Lesson: the expensive part of a review pass is not the reviewing. It is standing the map back up, and the passes best placed to find something rare are the ones paying most for ground that was already walked.
What a pass re-derives is not mainly file content, and counting tool calls said otherwise until
the labels were read. Reviewers here overwhelmingly run things: their own detached worktree,
their own scratch KB, pytest inside it, a throwaway probe script, then git worktree remove. On
one increment, seventeen separate passes wrote and ran uv run --frozen pytest
tests/test_pairing.py, and seven independently discovered that timeout is not installed on
this machine — each paying full price for the discovery. Lesson: the carry that matters is the
probe, not the file list. A map of what was read helps a reader; a command that already ran helps a
worker.
And the coverage hole nobody was looking for. The tool reports, per increment, the changed files
no pass ever opened: 211 of 248 across the corpus (85%; 92% on multi-pass increments). The
misses are not noise. src/pinakes/__init__.py — where __version__ lives — was never opened
across the 41 passes over 20260823_0718-mutation-batteries. And no review pass in this
corpus has ever opened the changelog.d/ or retro.d/ fragment its own increment wrote: the
files that carry this project's memory forward are the ones its review procedure never reads.
Lesson: "reviewed until clean" is a claim about what the passes looked at, and until something
lists the diff beside the reads, nobody knows what that was.
The method lesson, which is the one that generalises: a mutant that survives is a question, and
the answer was that my reasoning was backwards. The battery's first run left one row alive — the
mutant that flipped path normalisation from last-match to first-match. The test it named asserted a
property the fixture never created, so the mutant was unobservable. Answering why it survived
found that the shipped rule was the wrong one: last-match reduced tests/demo-kb/docs/x.md to
docs/x.md, which is not a file, and the tracked-file screen then discarded it. 102 tracked paths
are shaped that way. The written justification for last-match — that a branch directory might
contain docs — was simply false: a branch is 20260807_2143-docs-audit-findings, where docs is
bounded by hyphens and the /docs/ marker never matches it. Every gate was green throughout.
Two corollaries, both earned in the same hour:
- Then measure the fix rather than asserting it. The natural write-up was "every review pass that read the demo KB vanished from the map". Checked: 28 of 49,000 read targets normalise differently, 27 of them recovered, and the published shares do not move at all. Real, silent, and small. The defect class deserved the fix; the sentence deserved the smaller number.
- A number in a docstring is a claim with no gate on it, so give it a command.
--measurere-derives every figure in this tool's own docstring, under the key printed beside it. Two of those figures moved during the build, when the two defects above were fixed — and updating them cost one command instead of one act of remembering. This is the direct answer to the failure recorded intools/review_pass_gate.py, which shipped three measured claims that a change made in the same increment falsified.
And the fifth, which arrived while writing the fourth: the corpus is live, so a measurement over
it needs a stamp exactly as a measurement over a working tree needs a sha. Two peer sessions were
writing subagent transcripts into the same directory throughout this work. Running --measure
twice, minutes apart, with no code change between them, moved a published figure from 96.1% to
95.8%. Nothing was wrong; the corpus had grown. --measure now prints transcripts_read and
newest_transcript with every result, and the module docstring quotes both beside its numbers.
This is the same lesson a peer had reached from the other direction in the same hour — it dictated counted prose to two branches from two trees, neither containing the other's change, so whichever landed second would have made the sentence false. A stamped measurement that no longer matches the tree is dated; an unstamped one is merely wrong, and looks identical. The two of us met this from opposite ends of one morning, which is the strongest evidence either of us has that it is a property of working here and not of either task.
The review step ran before the fragment existed, so the fragment was never reviewed (20260826 11:30)¶
A peer measured that no adversarial pass in this repository's transcript corpus has ever opened
the changelog.d/ or retro.d/ fragment its own increment wrote, and that passes open only
207 of 248 files their increment changed — with src/pinakes/__init__.py, where __version__
lives, opened by none of the 41 passes over the mutation-batteries increment.
MEDIUM — the cause is not inattention, it is the order of the steps. docs/BUILDING.md lists
the adversarial review as step 5 and "a changelog.d/ fragment in the same commit as the code" as
step 6. The fragment does not exist when the last review pass runs. The procedure guarantees the
outcome the measurement found; no reviewer had to forget anything. That is a better finding than
"reviewers skip fragments", and it is only visible if you read the two steps as an ordering rather
than as a list.
MEDIUM — and it had already cost something, in the increment that was about exactly this. The
changelog fragment written in d9fe1a9 carried "wrong for twelve hours" — a duration that had
been invented, and repeated across four files, inside an increment whose whole subject was
claims asserted without measurement. The review pass did not catch it because the pass read the
plans and never opened the fragment. It was found while re-deriving timestamps for an unrelated
reason. A fragment is not scratch: tools/fragments.py splices it into CHANGELOG.md and
docs/RETROSPECTIVES.md, and docs/ publishes on every push — an unreviewed fragment is an
unreviewed published document.
LOW, and it is the caveat that keeps the rule honest. The rule says opened, not reviewed, and the peer insisted on the distinction before it was written down. A transcript can show which files a pass opened; whether it reviewed them is not observable and no tool will ever check it. So the checkable rule is the weak one, and it is stated as a floor rather than a goal. An opened file is not a reviewed file — the measurement is a lower bound on attention, never evidence of it. Writing the strong version would have produced a rule that reads as a guarantee and is not one, which is the failure class this repository names most often.
LOW — and applying the rule immediately caught a false negative in the checking itself. Verifying
that the d9fe1a9 fragment really had carried the invented duration, grep -o "wrong for.*hours"
returned nothing — on a file that contains the phrase. Markdown had wrapped it across a line
break, and grep is line-based. The first measurement said the claim was unfounded; the second,
tr '\n' ' ' before matching, said it was true. A grep over prose is a grep over lines, not over
sentences — so a phrase spanning a wrap is invisible to it, and the failure is silent and reads
exactly like a clean result. That is the shape of every defect in this increment: a check that
answers a narrower question than the one asked, and returns green.
MEDIUM — and opening my own fragments found that I had composed three timestamps today, in seven
fragments about not composing things. The repository's most-repeated rule is read the clock, never
compose it. Every fragment filename obeyed it — they come from date -u "+%Y%m%d_%H%M". Every
fragment heading I typed by hand, and three of the nine disagreed with their own filename:
| Fragment | filename | heading I wrote | out by |
|---|---|---|---|
…0727-a-procedure-that-never-asked |
07:27 | 07:26 | 1 minute |
…0733-the-cycles-price |
07:33 | 07:31 | 2 minutes |
…1130-…-never-reviewed (this one) |
11:30 | 08:00 | 3 h 30 m |
The last is the instructive one: I wrote 08:00 from a sense of how long the session had been running. The clock said 11:35. A composed timestamp is not approximately right — it is unrelated, and the error grows with exactly the thing that makes you stop checking.
And nothing catches it. Measured, not assumed. A probe fragment named …_0101-… with the
heading stamp 23:59 — twenty-two hours apart — was reported by
python3 tools/fragments.py --check as "all well-formed", exit 0. The filename prefix is parsed
(fragments.py:124) only to be stripped before reading the slug; the heading's stamp is never
compared to it. Both halves of the fragment resolve, so the same shape as every other defect in
this increment: a check that answers a narrower question than the one asked, and returns green.
The fix is a gate and it is tools/, so it is proposed rather than written: --check should
fail when a retrospective heading's YYYYMMDD HH:MM disagrees with its filename prefix. Both are in
the file; nothing external is needed; and it converts the repository's most-repeated convention —
which has now missed three times in one morning, in the fragments of the sessions writing about
measurement — into something that cannot miss silently.
The remedy for a bad survivor had a false-positive mode, and it was in the advice (20260826 11:46)¶
tools/batteries/README.md § Reading a SURVIVED row told you to run the mutant against the whole
suite to tell a mis-named witness from a real gap. That advice has a false positive, and it fires
on exactly the batteries covering tools/.
MEDIUM — the whole suite includes the test that fails on any edit to a battery target.
tests/test_batteries.py::test_every_anchor_still_resolves_exactly_once_in_the_file_it_names checks
that each battery's old string still resolves in the file it names — and a mutant is precisely an
edit to that string. So the suite goes red for a survivor exactly as readily as for a kill, and
"something else killed it" — the conclusion the paragraph tells you to draw — is unreachable from
the evidence it tells you to gather.
Measured, not argued. Applying tools-fragments.toml's first mutant to tools/fragments.py
(return line.rstrip() == "---" → return False) and running the two files separately:
| exit | ||
|---|---|---|
the row's own kills selector |
1 | a genuine kill — the behaviour is tested |
tests/test_batteries.py |
1 | 1 failed, 10 passed — the anchor check, not a behaviour test |
Restored, __pycache__ cleared, both green again: 51 passed. The fix is one flag —
uv run pytest --ignore=tests/test_batteries.py — now in the paragraph, with the reason beside it.
LOW, and it is the part worth generalising: tools/mutate.py was never wrong. It runs only the
selector a row names, so its report was honest throughout. The defect lived in the prose that told
a human what to do next, and prose is not covered by anything. A tool can be correct, its tests
green, its battery complete — and the sentence telling you how to read its output can still send you
to the wrong diagnosis. Found by a coder following the instruction literally and noticing the kill
was the wrong shape, which is the only way this class is ever found.
And the correction improved on the framing it corrected. The first version of this finding — mine — said a whole-suite mutant run "scored a kill that was an artifact", which reads as a defect in the harness. It is not: the harness never made the claim. Locating a defect in the advice rather than the tool is what makes it fixable in one flag instead of a redesign.
A ratio over a growing denominator, and a stamp that should never have been read twice (20260826 11:51)¶
Two conventions were relying on someone being careful. Both now say what to run instead.
MEDIUM — three figures I put into docs/BUILDING.md were wrong within four hours, and the second
reason is the one that matters. The rule cited 207 of 248 changed files opened, 83%, 90% on
multi-pass increments. The first reason they moved is ordinary: the measuring tool's scan could not
match an extensionless filename or a .lock, so Makefile and uv.lock were reported as
opened by nobody on every increment that touched them — a gap section confidently naming what the
tool cannot see. Fixed by its author; the figures became 211, 85%, 92%.
The second reason has no fix: the corpus is alive. It is this repository's own transcripts, and
the sessions measuring it are writing into it while it is measured. --measure moved a published
figure from 96.1% to 95.8% between two runs with no code change at all. A ratio over a growing
denominator has a shelf-life measured in minutes. So the paragraph now quotes the command —
python3 tools/review_ledger.py <increment> — and no percentage, which is the same conclusion
docs/VERIFICATION.md reached the same morning by a different route: two sessions dictating counts
from two trees, neither containing the other's change.
The general form, arrived at from opposite ends by two sessions in one morning: a stamped measurement that no longer matches the tree is dated, not false; a restated count is simply wrong. One of us met it by dictating counts from two trees, the other by watching a corpus grow under a measurement. That it was reached twice independently is better evidence that it is a property of working here than either route is on its own.
LOW — and the counterexample keeps the rule honest. The stamp gate proposed for
tools/fragments.py compares a heading's timestamp to its own filename. Both values are inside
the file, so it is immune to everything above: no corpus, no denominator, nothing to grow. It is
the exception that shows what the rule is really about — not never state a number, but never
state a number whose subject can move without the document knowing.
MEDIUM — and the convention that gate will enforce did not exist in writing. retro.d/README.md
showed a heading with a timestamp and never said where the timestamp came from, which left
name-early, head-late looking like a legitimate workflow and made a tolerance look reasonable.
It is now written: the heading's stamp is a copy of the filename's prefix — one reading of the
clock, written twice. That makes exact equality correct by construction rather than by taste, and
it was a coder's question about tolerance that exposed the gap: a gate cannot be specified against
a convention nobody wrote down.
Red on the clock, and five measurements of one string that all failed (20260830 09:29)¶
MEDIUM — a suite went red with no commit, and the design had already decided it should not.
prices.toml aged past max_price_age_days (30) on 20260827 and 25 tests began failing. Not one
line of code changed. docs/DESIGN.md §5 had chosen a runtime refusal plus a doctor WARN so
staleness would not gate a build — and then 25 tests asserted the un-stale path without pinning a
clock, so the suite quietly became the gate the design refused. The defect is neither the
constant nor the table's age: it is a test that inherits today's date and asserts an outcome that
depends on it.
And the precedent cited against wall-clock checks was this file. tools/status_header_gate.py:52
declines a staleness check because "a wall-clock staleness check fails on a quiet weekend with no
code change" — naming prices.toml as the reason not to add one. prices.toml then did
exactly that, after a quiet four days.
MEDIUM — two adversarial reviews died the same way within hours, and both reported a clean bill.
52 agents, 26 dead; 19 agents, 17 dead, the second returning a literal
{"raised":0,"confirmed":[],"refuted":[]}. Two independent instances in one day is the contract,
not bad luck — a dead agent becomes null and .filter(Boolean) erases the evidence. Both were
recoverable from agent transcripts on disk, which makes it a procedure rather than a warning.
tools/review_ledger.py exits 1 on an unfinished pass and would have caught both before either
result was read. A third hole was nobody's session limit: the workflow capped a lens at
.slice(0, 12) when it raised 16, and logged nothing. Three of the four dropped were real.
HIGH, and it is about the author rather than the tools: five measurements of one string failed
before the truth came out. The claim was that a fragment "written in d9fe1a9" carried the
phrase "wrong for twelve hours". Checking it:
| attempt | result | why it lied |
|---|---|---|
grep for the phrase |
no match | the phrase was wrapped across a newline; grep is line-based |
tr '\n' ' ' then match |
no match | Markdown indentation left multiple spaces between the words |
the same check in a shell for loop |
0 for every sha |
a quoting artifact; the file plainly contained it |
reading 29856b9 |
"confirmed true" | the sentence names d9fe1a9, a different commit |
uv run … \| grep …; echo $? on a gate |
0 on a failing run |
$? is grep's status |
The claim was false and three independent agents said so while the author confirmed it twice. Four of those five failures are the same shape: a check that answers a narrower question than the one asked, and returns something indistinguishable from a clean result. A shell one-liner composed in the moment is not an instrument — and the author is the least reliable verifier of the author's own text, which is the case for spending agents on prose nobody else has read.
LOW — and the handover rule caught itself. All of the above lived only in RESUME.md, which
docs/BUILDING.md calls "a
convenience, never a carrier": excluded from git, invisible to every other checkout, "cannot
discover that one exists". The rule is the planner's own and the planner broke it — by putting
a repository-wide blocker somewhere no other session could ever find. That is what this commit fixes.
Unblocking the clock-red suite: the seam was the table, not the clock (20260830 14:31)¶
MEDIUM — a true observation pointed at a fix that would have reached none of the failures.
The impurity was real and was enumerated correctly: four unconditional datetime.now(UTC) reads in
callers (cli.py:874, cli.py:1259, extract/claude.py:649, doctor.py:1586), against one
existing seam at cli.py:667. The inference — so add seams to the other three — did not follow.
tests/test_cli_ask.py drives real main([...]), so there is no now to inject, and seaming all
three would have made zero of its 18 failures reachable. Every clause was true; the population
the argument was about, the failing tests, had never been examined. The seam that works is the
price table, which the repository already did twice
(tests/test_deep_loop.py, tests/test_extract_claude.py) without either helper being noticed by
the pass that enumerated the clock reads.
MEDIUM — the fix is a fake, so the honest version fakes exactly one field. An autouse fixture
that returned a synthetic price table would have taken the committed prices.toml out of the
suite's reach entirely. Calling the real load_prices() and replacing only as_of leaves every
model price, the FX rate and the parse of the committed file on the real path: a defect in any of
them still fails the same tests. The remaining unreachable inch was named before it was accepted —
and then measured, not argued: with as_of = "28 July 2026" committed, the suite is still red
in 7 places. Nothing was lost from the catch. What was lost was the sentence, since all seven are
subprocess gates that report a free-path failure rather than an unparsable timestamp, so
test_prices_are_installed_package_data — which already asserts the format of every other field in
that file — now parses as_of too.
MEDIUM — a review finding is a hypothesis until a control runs, and two of mine died there.
The adversarial pass over this fix raised two gaps: no test pins the CLI refusal on a stale table,
and nothing notices a malformed committed as_of. Both were plausible, self-consistent and
directly implied by the change. Both were wrong on inspection.
test_cli_ask.py::test_a_price_it_cannot_compute_leaves_the_free_command_working already pins both
halves of the refusal and its docstring already says a stale table takes that branch; the malformed
case is caught seven ways. Written up unchecked, the pass would have added a duplicate test and a
docs/VERIFICATION.md row for a promise already rowed. The review's output was one line, and its
value was the two things it stopped.
LOW — "the fix is green" is three claims when CI is a three-leg matrix. Fail-fast names whichever
leg loses the race, so a run summary reports one leg and hides two. Each leg was run separately
rather than inferred: [light] 2234 passed / 126 skipped, [light,pdf] 2352 / 8,
[light,pdf,claude] 2356 / 4 — and the six tests that only the extras can reach are exactly the
six a [light]-only checkout skips, which is why two sessions counted 19 and 25 all morning
without either being wrong.
And the defect's own cause was sitting in the file the fix lands in, written as an assumption.
test_a_stale_price_table_warns_and_names_the_setting's docstring opened "the shipped table is
current by construction" — one function below the test that failed because it is not. The
correction lands in the same change, or the fix leaves the premise that produced the defect
in place. A docstring is a claim with a shelf life and nothing checks it.
S16/S19 — A fix that enforces a property globally unpins every test that asserted it locally (20260826 11:47)¶
HIGH — the finding is about the mutation harness, not about renames, and it is the most portable
thing this increment produced. The ordering pass makes "no action writes a path a live row still
holds" true of every plan. Two mutants in src-pinakes-pairing.toml died on main to that
property being locally violated — a SoftDelete landing after the Adopt it belongs before —
so both became invisible the moment the property was guaranteed globally. Neither
--check-anchors nor tests/test_batteries.py can see this happen: the anchor still resolves and
the kills selector still names a test that exists, which is the entire contract those checks hold.
The generalisable rule: when a change makes a property hold by construction, every test that
asserted that property by observation stops discriminating — and a battery is exactly a corpus of
such tests. Run the battery after a change of that shape; the anchors resolving is not a substitute.
The one that had nothing underneath it was still worth the run. Inverted guard was killed by
eleven other tests: the battery was right about the risk and wrong about the witness, which
tools/batteries/README.md already names. It was repointed and renamed — its old name described
the in-place half, which the ordering pass now repairs, and what still dies is the other branch.
A row's name is a claim too, and it rotted without its anchor moving.
HIGH — the one that did have something underneath it was an untested route to breaking ULID
permanence. An orphaned sidecar counts as a claim survived on the branch and nothing in 2 212
tests saw it. Reading why found a second reader of claimed_by_id that the ordering had been
standing in front of: a file with no sidecar of its own, whose index id turns up claimed
elsewhere, is read as "this row's identity has moved" and its path is left to be minted fresh. Let
an orphaned .pnk.yaml count and that fires on an untouched document sitting beside it — re-minted
under a new id, the original retired, every inbound pnk:// link dead. That is
docs/INVARIANTS.md's first
invariant, reachable with the user doing nothing wrong, and it had no test. The new one asserts the
action kind, not the order — the half a global ordering guarantee cannot repair — and was watched
red under the mutant first: Skip becomes Rename.
MEDIUM — the battery README's own remedy has a false-positive mode. It says: before believing a
survivor, run the mutant against the whole suite. The whole suite contains tests/test_batteries.py,
whose anchor check fails on any edit to a battery target — so a mutant that dies only to that is
not killed, and a run reporting 1 failed, 2 221 passed reads as a clean bill. tools/mutate.py
is blameless: it runs only the named selector and its report was honest. The defect was in the
advice, which makes it a one-flag fix (--ignore=tests/test_batteries.py) rather than a redesign.
MEDIUM — working code was written, tested, and backed out, and that was the right call. Making a
rename cycle raise cleanly from pair() is tidier than letting it fail at the first write: nothing
is applied, so nothing is half-applied. It broke five committed tests, and three of them are guards
that exist only while a cycle still produces a plan — including one pinning the silent-loss shape
the previous increment exists to prevent, which can only observe it by watching a plan be applied and
fail. Refusing the cycle is a decision about behaviour that costs coverage of a different defect,
not an implementation detail of an ordering fix. The price is now recorded where the next person
reaching for it will start from it: not "add a temporary path" but "add a temporary path and
replace three guards."
LOW, and a lesson about test seams rather than about this code: a stability test that could not reach the thing it tested. The ordering pass returns early when a plan has no constraints — so the test that pinned "a plan needing no constraint comes out unchanged" never entered the topological sort at all. A mutant emitting ready actions in arbitrary order therefore changed the output of every constrained plan and survived that test and the whole suite. A test named for a property is not evidence it reached the code implementing it; the repair was a plan carrying a constraint and untouched actions beside it, so the sort runs and the unconstrained actions are observable.
LOW — ty caught two typing defects pyright strict passed, both in new test code, both real
(an attribute read off a union member that does not have it). check.sh calls ty a fast pre-check
and never the gate, which remains right — but on this increment it was the only checker that saw
them, and that is worth one line in the record rather than a re-litigation of the policy.
A gate green on main and red on the tree main is about to become (20260830 15:04)¶
MEDIUM — three sessions measured one gate within an hour and got three answers, and every
measurement was correct. The planner reported release_order_gate red and asked for a fix. A
second session ran it on main at 37689f1, measured green, and — instead of reporting the
planner mistaken — said so and named its sha. The planner then re-measured, found it had reported a
property of its own uncommitted worktree as a property of main, and corrected itself unprompted.
The resolution came from neither: reproducing the failure before writing a line. Copying the
three documents to a scratch tree and appending 0.31.0 exactly as the post-publish sweep does
produced the planner's two failure lines verbatim.
So the accurate statement is one none of the three had: the gate is green on main and red on the
tree main is about to become. Both PyPI sequences declare newest_may_lag, so while 0.30.3 is
the tail its absence reads as latency; adding 0.31.0 makes it an interior hole, which lag does not
cover. A blocker observable only from a tree that does not exist yet is still a blocker — and it
is invisible to exactly the check a careful reviewer runs, which is git rev-parse HEAD followed by
the gate. This is the third measurement-over-a-moving-target failure recorded in one day and the
only one where the target moved forward: the other two moved backward, a suite read across a
release cut and a TZ=UTC git log --date=format: printing a committer-offset time as UTC.
LOW, and the reason the fix needed three tests rather than one: a declared absence and a hole
nobody noticed have the same exit status. The green run's (declared absent: 0.30.3 — …) sentence
is the entire difference, so one mutant silences that sentence and nothing else, and it is killed by
the real-documents test rather than by any of the new ones. The control that matters strips the
declaration and requires red on exactly the two PyPI sequences — built on a fixture rather than
the real documents on purpose, because before the sweep lands the real tree is green with or without
the declaration, so a control there would prove nothing today and something different tomorrow.
A test whose meaning changes when an unrelated commit lands is not a control.
And the scope is the whole claim. 0.30.3 is a real release document and only never a published artifact; a blanket exclusion would have made the gate green by erasing that distinction. All three new battery rows attack the scope rather than the mechanism — the mechanism was already covered by 0.11.0's rows — because what is new here is which sequences carry the declaration, and that is data, which no type checker reads.
S18 — the obvious fix would have shipped the defect it claimed to fix (20260830 15:15)¶
HIGH — the right answer was written down, one layer down, and taking it would have been wrong.
sync.py:2190 already handles this case beautifully: it decides from the sidecar's own recorded
content_hash rather than from a cache miss, peeks the extraction cache, reuses paid text for free
when it is warm, and raises the honest PaidExtractionUnavailableError when it is not.
docs/DESIGN.md:1036 names that as the required outcome. So the obvious fix was to stop pairing.py
pre-empting it — emit Reembed and let the layer with more information decide. Every sentence of
that is true and the change would have introduced a Decision 9 violation.
What stopped it was reading _paid_survivor_in_current_index instead of trusting its name: it
selects WHERE id = ? AND state = 'active', so a revived row gets no index-side protection at
all. The only remaining guard is sync.py:2190, which reads the sidecar's provenance — and
WalkedSidecar carries path, document_path, id, file_hash and nothing else, so pair()
cannot distinguish a document the cache could revive from one a free backend would silently
downgrade. The fix that removes a false claim would have shipped a silent one, under a commit
message about honesty.
That turned an implementation choice into a decision with a price, which is the user's and not
mine. Three options with a real trade-off — honest reason only, route to the extractor, or widen
WalkedSidecar to carry provenance — were put to the user with the cost of each. They took the
first, 20260830: the reason is corrected and nothing else moves. The cost is stated rather than
buried, here and in docs/DESIGN.md's row: a user whose extraction cache is warm still pays for
text their machine already holds.
The distinction that made it a stop rather than a judgement call is worth keeping. Choosing how to implement what a plan specifies is an implementer's; choosing what it should have specified is not. The plan said the fix "must keep the paid-protection clause and stop conflating retired with changed" — all three options do exactly that, and they differ only in whether a warm cache may spare the user money. That is a question about spending, and no amount of reading the code answers it.
MEDIUM — a census cannot see a reason, so nothing but an assertion on the reason catches it.
describe() returns {"PaidExtractionRequired": 1} whether the plan says the content changed or
the row was retired, and both are one action either way. The mutant that inverts the precedence —
RETIRED if retired else CHANGED — is therefore invisible to every structural check in the module,
and it fails in the direction that matters: a user whose file genuinely did change is told nothing
about it did. It is the obvious implementation, and it is the one a reader would write.
MEDIUM — a field is not a message. The defect was never in a field; it was in a sentence a user
reads. So the claim is pinned twice: a unit test on which reason the plan carries, and a
test_sync.py test that drives a real delete-and-restore and asserts what is printed. The unit test
alone would have passed against a build whose sync still said "content changed" for both reasons —
the seam rule applied to a string rather than to a transport.
LOW — the reproduction that made all of this cheap was six lines and one changed field. The same
inputs with state=ACTIVE give Skip and protection; with state=DELETED they give
PaidExtractionRequired. A control that differs in exactly one field is worth more than a paragraph
of reasoning about what the branch does, and it is what let the fix be scoped in minutes rather than
argued.
S16's residue — "containment is one line", and the line was too wide (20260831 21:58)¶
The review was right about the defect and wrong about the fix, and the two are easy to accept
together. Two independent adversarial agents found the same residue on the S16 branch: ordering a
rename chain creates a dependency between actions that the executor knows nothing about, so one
member failing to index leaves its row in place and the next action collides. Both wrote the same
remedy: add sqlite3.IntegrityError to _apply's except tuple. One of them called it "containment
is one line", which it is.
It is also one line that swallows chunks(doc_id, ordinal), nodes(kind, key), the links and
edges primary keys, and the CHECKs on documents.state, links.origin and nodes.kind. Each of
those fires only when Pinakes itself is wrong. _apply records a failure and continues — so a
bare catch would file an invariant breach as one document's problem and let the run report success
around it, which is the exact silent shape docs/INVARIANTS.md exists to prevent. The finding's
severity was HIGH and its proposed fix would have opened a second hole to close the first.
The third option was not in the branch, not in the plan, and not in either review. It came out
of asking one question the reviews had not: how wide is IntegrityError here, actually? Reading
store.py answered it, and exc.sqlite_errorname — measured on the interpreter rather than
remembered from the docs — turned out to separate SQLITE_CONSTRAINT_UNIQUE (2067) from
SQLITE_CONSTRAINT_CHECK (275) and SQLITE_CONSTRAINT_PRIMARYKEY (1555). That made a narrow catch
cheap, so the decision put to the user was three options rather than the two the branch offered. A
review's recommended fix inherits the framing of the review, and a reviewer who has just proved a
defect is not the least biased party about its remedy.
The battery found the hole in my own test, which is the part worth keeping. The witness for the
narrow behaviour had three negative cases — a duplicate primary key, a CHECK breach, a
chunks(doc_id, ordinal) collision — and it passed. It also passed with the sqlite_errorname
clause deleted, because all three fail on the column substring alone, so the clause the whole
decision rests on was never exercised. The case that separates them is
NOT NULL constraint failed: documents.path: the right column under the wrong error code. Three
plausible negative cases agreeing with each other is not coverage — they have to disagree about the
clause under test, and nothing but writing the mutant asks whether they do.
The exit code came from the adversarial pass, not the build. Catching an exception trades a
crash for something quieter by construction, so the question "does pnk sync still exit non-zero?"
had to be asked separately. It does — report.ok is not self.failures — but nothing pinned it,
and a later edit to ok could have turned a contained failure into a silent success with every
test green. It is asserted now.
S3 — a thread id is a slot the OS reuses, not an identity (20260831 22:21)¶
- A defect in how threads share a handle is invisible to a suite that never starts a thread.
tests/test_serve.pyhad no thread anywhere in it. The bug was not missed by a weak assertion; it was outside the shape of every test in the file. The trigger was a property of the transport, and the transport was the part nobody was testing —serve.pystarts no threads, so reading it end to end tells you it is single-threaded, and that reading is what the tests encoded. - The inherited explanation of when it fires was wrong, and only running it said so. The
finding came with a mechanism — a worker retired after ten idle seconds, so the first call after
a pause gets a new thread — and it is a correct account of
anyio. It is not what fails. Driven through the realmcp.call_tool, an eleven-second pause reproduced nothing: the retired worker's thread id had already been reused by its replacement, andsqlite3compares ids, so it saw one thread. What did reproduce, on every attempt, wasclose()— shutdown runs on a thread that opened nothing — and, under six concurrent calls, four of six raisingProgrammingError. The trigger is contention, not idleness. The mechanism was sound, the population was never checked, and the first commit message asserted the pause as though it had been measured; it is corrected in the follow-up rather than left to read as evidence. - A thread id is a slot the OS reuses, not an identity — and it faked a passing test. The first
version of the rebuild test ran two worker threads in succession and passed against the unfixed
code. macOS had handed the second thread the id of the first the moment the first was reclaimed;
sqlite3compares ids, saw one thread, and allowed the reuse it exists to refuse. Measured directly: three successiveanyioworkers reported one identicalget_ident()and three differentThreadobjects. Two consequences, one for the test and one for the fix — the opener must be a thread that is still running when the reader starts, and the per-thread cache is keyed by the thread object, because keying by id both hands a new thread a dead one's handle and makes a dead entry indistinguishable from a live one, which is the entry reaping exists to find. - The control is what turned this from a plausible fix into a pinned one. Reverting the fix
failed three of the four tests in their bodies; the fourth — the handle-count bound — is red
under the old design only because the attribute does not exist, so it was checked separately by
deleting
_reap_dead_threads()alone (assert 12 <= 2). Without running both controls the weak test above would have shipped as a certificate, and its docstring now says which control it answers to. - The seam is named in the test that has one. Driving real
threading.Threads proves the handler is per-thread; it does not prove the transport ever uses a second thread. That half is the whole premise of the fix, so it is asserted againstmcpdirectly rather than assumed — a probe tool that records the thread it ran on. If the library stops offloading sync tools, that test goes red and says so, and nothing else in the suite would have noticed. - The measurement that corrected it also found a second defect nobody had named. Counting the
connections actually opened showed the old
connection()testing and assigning one shared slot with no lock: threads arriving together each opened one, the last assignment won, and the losers stayed open, unreferenced and unclosable. That race is why some concurrent calls answered — a thread that won it was using a connection it had opened itself — so the leak was also the thing hiding the failure. Neither would have been found by reasoning about the fix; both came from instrumenting the run and asking how many, rather than whether.
The release step written to catch a stale price, skipped by the release that wrote it (20260831 22:53)¶
- Five hours separated the rule from the violation, and both are in the same day's history.
a2cc944(20260830 10:57) addeddocs/RELEASING.md§ Before you start step 3, including the warning that "re-stampingas_ofwithout re-verifying the numbers is not a refresh — it is falsifying a measurement" and a table whoseusd_per_eurrow says "this is the field that actually moves".c7b0bd9(20260830 15:53) cut 0.31.0 and changed exactly one line ofprices.toml:as_of. The rate the table singles out was left at its 20260728 seed. A step's first run is where it is weakest, because the person running it is the person who just wrote it and is checking their own text against their own memory of it. - The model prices are the field that invites the skip. Step 3's other row says of them
"unchanged is the normal outcome", and they were unchanged —
claude-opus-5is still5.00/25.00, re-confirmed again today. Verifying the field that will not have moved feels like performing the step, and it is the half that carries no information. The order in the table now reads as a trap: the reassuring row is first. - The staleness guard was healthy the whole time, and guarded the wrong thing.
max_price_age_daysandpnk doctor's WARN both key onas_of, so a re-stamped table is maximally fresh by every instrument the project owns — which is precisely how a wrong number survives. An instrument that reads the claim cannot check the claim. The only detector is going back to the source, which is why step 3 asks for a named one; an unnamed source is not re-checkable by the next release, soprices.tomlnow namesapi.frankfurter.devin the file itself rather than in a commit message nobody will re-read. - Two independent sources, because being wrong here would have looked exactly like being right. The ECB reference rate for 20260831 is 1.1596; a market quote for the same day gives 1.1617. They agree to 0.2%, which is the difference between a daily fix and an intraday spot, and either would have refuted 1.08. One source would have been a number I could not distinguish from a typo.
- The error's direction is the reason it was survivable, not a reason it was harmless.
1.08is below the true rate, socost_usd / usd_per_eurover-stated every euro figure by 7.4% and every EUR cap bit early. Nothing overspent. But the ledger recordscost_usdand the rate on each line precisely so euros can be re-derived later, and lines written since 20260728 carry a rate that was never true — the re-derivation is faithful to the record and the record is wrong. - Nothing in the suite was watching the number, and the thing that went red was an accident.
Checked exhaustively rather than by sampling — every EUR literal under
tests/at3cf9858, seven candidates, each one opened. Six are immune, and all six for the same reason: they pin the rate where the test controls the input (a local constructor default, a moduleRATE, a per-callrate=, a fixture deliberately at1.00, a value put in and read straight back). The seventh,test_cli_ask.py:374, reaches the shipped rate only becauseconftest.py'sprices_never_agereplacesas_ofalone — and it exists to discriminate €0.21 from €0.26, not to watch FX.test_budget_core.pyasserts the committed rate is a string that parses as aDecimal, never its value;check.sh's prices gate checks only thatas_ofparses, and says at length why it is not a staleness check. So the shipped rate's sole guard was a literal nobody wrote for the purpose — and it fires when the rate moves, never when it is stale, which is the wrong half of the property. A month of1.08produced no signal from anything. - Which makes "fix the coupling" a trap. The obvious follow-up — decouple that literal from the shipped table — deletes the only alarm the rate has ever had, and would read as tidying. The fix is two parts, not one: pin the rate at that call site, and separately assert the committed rate is plausible. A bound is wrong in one place and right in the other, which is why one change cannot do both: a bound at the call site would admit 0.26 and destroy the discrimination the test is for, while a bound on the rate is exactly right, because the property there is "somebody refreshed this" rather than "it equals X". And the honest limit, stated rather than sold: a bound cannot catch stale-but-plausible, and 1.08 was plausible for the whole month it was wrong. The mechanism is step 3; the bound is a backstop against a typo or a seed value left in place.
- The find came from executing the procedure, not from auditing it. Step 3 was read tonight in order to run it, and the defect surfaced in the first thirty seconds of doing so. Two days of document audits over the same tree — including one that examined this plan's own file — had not looked at the value.
A population that could not have contained the thing I was looking for (20260831 22:59)¶
- I reported a zero from a population that structurally could not contain a non-zero. The claim
was that
claude-fable-5— which bills at exactly 2.00×claude-opus-5— had never been used by a fan-out: "subagent runs: 0; workflow agents: 0 of 868". It reached a planner, and it is onorigin/maininplans/20260831_2242-agent-spend-two-findings.mdas "an unguarded path, not a realised cost." It is false. Measured with the committed script: 316 fable-5 requests in workflow agents and 235 in subagents, all projects — 551 of 866, or 63.6%, across 26 distinct transcripts, billing on three days between 20260709 and 20260821. The reason I saw zero is that I was counting a main-loop-only population: subagent transcripts live under<session>/subagents/and were never in the file list. The query was sound, the domain was never named, and the answer was exactly the shape that made it look confirmed. - The same session had just written that rule down for someone else. The document this error landed in exists to record that a claim resting on a selected population must state the selector. I supplied the zeros for it. A rule you are enforcing on a peer is not a rule you have applied to yourself, and the direction of the failure — a zero, which reads as nothing to worry about — is the direction that gets no scrutiny.
- Committing the script is what found it. The figures were re-derived by the tool rather than
recalled, and the fan-out numbers appeared the moment
include_subagentswas a parameter someone had to pass rather than an assumption nobody had written down. Two other reported figures did not survive the same treatment: the cache-re-write counts (135 events,43,946,201 excess units) reproduce at no cutoff time under any population tried — the nearest is Pinakes main-loop, which now gives 149 and 45.1M. Those never reached a document; the fable zeros did. - Before trusting a cross-file total, test the overlap. Main-loop and subagent transcripts share 0 request ids of 20,156 and 23,685, so the combined totals are sound rather than double-counted. That check cost one query and was the difference between a reportable number and a plausible one.
- The fix is a default, not a caveat. The tool as first landed hardcoded its file list per
subcommand, so the main-loop-only dollar share a document needs to cite was not reachable by any
flag — the committed instrument could not reproduce the number the committed document quotes,
which is the whole failure it was built to end.
--scope {all,main}now selects it, defaulting toall, and every subcommand prints the population it read. The direction of the default is the lesson: the restricted population is the one that must be asked for by name, because a restricted list does not announce itself — it just returns a zero. - Then I did it again, in the sentence correcting it. The retraction said the fan-out requests
sat "across 26 transcripts dated 20260803 and 20260821". The 26 is right — its selector is all
projects, subagent and workflow transcripts only, matched on
message.model. The dates are wrong. I printed the first six rows of a 26-row list and read the span off the part I could see; the real span is three days, 20260709 · 20260803 · 20260821. A truncated listing is a selected population wearing different clothes, and it went into a commit message. - A count is not a measurement until its selector is stated, and the selector fails in both
directions. A peer scanning the same corpus got 59 files where I got 33. Neither was careless:
59 is every file where the string
claude-fable-5appears; 33 is every file with a line whosemessage.modelisclaude-fable-5, which is exactly the set that billed anything. The 26 extra files are ones that merely discuss it — including the sessions doing the measuring. Measuring a model by grepping for its name counts your own analysis as data, and the more carefully you write about it the larger the number gets. - A split that cannot be reproduced does not get published, and the fix is a flag rather than a
footnote. 316 workflow-agent and 235 subagent requests were true and unpublishable:
--scopeofferedmainandalland nothing between, so quoting the split would have meant quoting a number the committed instrument could not produce — the defect being corrected, one layer down.main,subagentandworkflownow partition the corpus, asserted by a test rather than by arithmetic in a message: on one snapshot, 20,498 + 6,594 + 17,462 = 44,554 =all, file sets disjoint and their union exact. Run across four invocations instead of one, the same sum is off by two — the corpus grows while you measure it, which is why a figure carries the hour it was taken. - The same failure has a time axis, and the tool had no guard on it. A peer read a workflow's
journal while the workflow was still running, saw 8
startedagainst 7result, and reported the difference as a lost agent. It was the judge, mid-run; the workflow completed 9 of 9 minutes later. The reading was correct and the question when was this read had not been asked — the same defect as out of what population, one axis over. My own analysis had controlled for it only by observation ("the most recent orphaned journal is 20260826, so none is in flight"), which is a fact about that afternoon and not a property of the instrument.workflowsnow excludes runs whose journal moved in the last--settle-minutes(default 60) and prints how many it excluded, because a silent exclusion is another unstated population. Journal rows carry no timestamp of their own —agentId,key,result,type— so the guard is the file's mtime, and a journal whose age cannot be read counts as settled: the guard drops what it can prove is recent, never what it merely cannot date. With the guard the corpus reads 55 runs / 868 agents / 157 no-terminal-row; without it, 57 / 881 / 159. - An aggregate is an instrument too, and it has a selector of its own. The span above was wrong
twice: first read off a truncated listing, then "corrected" using each transcript's file
mtime, which gave four days instead of three. A peer caught it, and checked the instrument the
obvious way — against the union, where mtime and the billing timestamps differed on exactly one
file in 26, an error rate small enough to write down and move on. Checked per file it is
six of 33, four of them by 46 to 48 days: sessions resumed or recompacted weeks after they
stopped billing. Five of the six were invisible because they landed on days the union already
held. So a union does not merely lose per-file error, it reports the residue as the error rate
— and validating an instrument through an aggregate understates it by whatever the aggregate
happens to swallow. That is the population failure one level down, found in the paragraph
written to correct the population failure. This lesson is deliberately recorded twice — here,
and in
plans/20260830_0927-main-is-red-and-a-review-that-half-ran.md§ An eighth instance, which registers the incident while this records the principle. The plan closes; this does not. Correct either and you must correct both. The precedent is § Reconciling the queues — two registers of one fact, and an owner with nowhere to queue above:plans/20260825_1252-plans-sweep-findings.mdkept two registers of the same facts, a later pass updated the prose and not the table, and twelve of its 27 rows stopped describing the tree with no gate able to see it. A second copy is a decision to maintain two things, not a free backup. - Four wrong spans in one night, none of them arithmetic, and every one caught by a re-run. The fable zeros (a borrowed denominator), a peer's 59 transcripts (a string match that counted the measuring sessions), another's 14-of-100 (a denominator from a set the rule is undefined on), and mtime-for-billing-day. Each was a correct measurement of something nobody had asked about. None was caught by re-reading a document; three were caught by someone re-running the query differently and one by re-running it on a corpus that had settled. That is the case for the instrument being committed rather than its output being quoted — a number in a document cannot be interrogated, and the disagreement between two people running two selectors is the only thing that reliably surfaced the selector at all.
I corrected a document I had not read (20260831 23:26)¶
- The first version of this fragment was the defect it now records. It opened by quoting "the
queued row" as calling this "a probe silently measuring a question set a rebuild overwrote",
and spent its lead bullet correcting that framing as a size too big. No plan has ever contained
that sentence.
git log --all -S"silently measuring"returns exactly one commit — the one that added the fragment. The row (plans/20260825_1240-run-pinakes-sweep.md:579) says the probe hardcodes its questions path "with no way past it", which is precisely the framing I claimed it lacked. The runbook (plans/20260803_2239-corpus-probe-run.md:34) does say "silently" — about the file being clobbered by the next build, where the silent party isbuild_rfc_corpus.py, which overwrites unconditionally and tells no one. My sentence compressed two documents and moved the adverb from the writer onto the reader. The original text is in1d5e7acand stays there; a retraction whose original has been deleted is a claim nobody can check. - It came from my own session's handoff table. The previous coder session's handover, pasted in
at my start, summarised the row as "stops a probe silently measuring a question set a rebuild
overwrote" — one cell in a three-row table. I read a paraphrase as a quotation, corrected a
document for it, and put that correction into a retrospective, a commit message and a message to
the planner before opening
plans/. A handoff table is a lossy summary of a document, and the session receiving it cannot tell a paraphrase in one from a citation. The cheap defence is afile:linein the handoff instead of a summary; the reliable one is opening the file. - The ownership rule is what kept it out of
plans/. Documents here have one owner, so the correction could only be proposed. It landed in the two places an implementer writes for itself — a fragment and a commit message — and nowhere else. That is the mechanism working, and it is worth saying because the rule usually reads as friction. - What was actually true, verified rather than asserted: the probe has recorded the golden
set's resolved path,
sha256, question count and multi-hop count in both output formats sincea6a931b(20260804 04:13 UTC), three days before the runbook warning and 27 days before the row. So nothing was ever misreported, and the row's own scoping — no route to re-measure a replaced set — was right all along. Three lines, no guard. - Two reviewers found it; neither found the half that mattered. Both raised it as a fabricated quotation, which it is. Going to the row myself, instead of accepting a confirmed verdict, is what turned up that the correction was also substantively wrong — there was no oversized framing to walk back. An adversarial pass that agrees with you about what is wrong can still be a level short on why, and a confirmed finding is a place to start reading, not a conclusion.
- The same shape twice in ninety minutes, on a denominator this time. Arguing against gating a fragment's heading stamp, I measured 14 of 100 retro fragments whose stamp is not a copy of the filename's prefix. The rule is undefined on the sixteen fragments that predate the naming convention, so the population is 85, not 101; the numerator was right and reproduced independently, and only the denominator was borrowed. A ratio is a claim about its denominator. Recounted on the population the rule actually covers, 18 of 85 were wrong at the moment they were committed — which is an argument for the gate I had just argued against.
- And that recount had an unstated selector of its own, which is the same failure one level in.
85 counts
git log --all, so it includes fragments on branches nobody has merged — this one's included. Re-run at a named commit: atd66acb8, 100 fragments ever added, 84 prefixed, 16 not, and 18 of the 84 were wrong at the moment they were committed. That is the planner's published 18 of 84 exactly, reached by a different method.--alland a ref are different populations, andretro.d/gains files hourly — a census of it that does not name its commit is not a measurement. Caught before it was raised with the planner, by re-running with the selector stated, which is the only step that has ever caught one of these. - The total is robust and the breakdown is not, which is worth more than either number. Three
independent counts agree on 18 and disagree on how it splits: 2/8/8, then 4/3/11, now
4 with no heading at all, 5 with no stamp, 9 with a stamp that is not the filename's. Nothing
is wrong in any of them — the boundary between no stamp and wrong stamp is a definition each
count drew for itself, and none stated it. A breakdown inherits every ambiguity the total
averages out, so the gate is argued from 18, and the split is not published. One byproduct:
reading each file at the sha that added it (
git show <sha>:<path>) resolves all 84, so the three lost to rename detection in the ruling are an artifact of the method, not of the corpus.
A composed number, again — and a test that was not a pin (20260901 06:31)¶
Two findings from the adversarial pass over the --questions guard, kept because neither is about
--questions.
HIGH — the third block of the guard's own test asserted nothing. It claimed to catch the
plausible widening of the guard to the KB's default golden set, and its KB was
tmp_path / "kb-without-a-golden-set" with mkdir(). An empty directory has no pinakes.toml, so
load() raised ManifestError and execution never reached the default questions path at all. Both
assertions — returncode != 2, "no golden set at" not in stderr — were satisfied by that
unrelated crash. The reviewer patched the widened guard in and the test passed. It is a real
copy of the demo KB now, with only eval/questions.yaml removed, plus a third assertion that the
default path was actually reached, and mutant 6 in the battery is the widening it now kills.
The rule this breaks is the house one: a test is a pin only if reverting the fix turns it red. The subtler half is that I never applied it here, because there was no fix to revert — the block asserts a **non-**behaviour, that something is deliberately not guarded. A negative assertion has no fix to back out, so the pin test has to be run forward instead: apply the change the block exists to forbid, and watch it go red. Nothing in the procedure said to do that, and I did not.
MEDIUM — "a nine-frame FileNotFoundError". It is five. Module, main, load_questions,
read_text, open, measured on b47eda6. The number went into the commit message, into a comment
in shipped code, and into the test's docstring, in the same phrasing three times. Nobody counted it.
That is the second composed number on this branch in one evening, and the fourth of the day
across sessions. The first was a quotation attributed to a plan row that the row does not contain;
this one is an integer attributed to a traceback nobody ran. The pattern is not carelessness about
facts in general — every load-bearing claim on this branch was measured. It is that a number used
as texture, to make a sentence concrete, does not feel like a claim while it is being written.
retro.d/README.md already names this exact failure for timestamps — "composing it instead is the
failure this rule exists to stop" — and the timestamp rule works because it names the instrument:
date -u, once, pasted twice. A frame count has no such instrument, so: if a sentence contains a
number, either it came from a command in this session's scrollback or it does not go in.
Both were found by review lenses, not by me, and neither would have been found by re-reading: one
needed a patched mutant, the other needed grep -c ' File ' on a traceback nobody had generated.
That is the same conclusion the day's other four wrong claims reached from four different
directions — re-running is what catches these; re-reading is not.
A third pass found the repair worse than the defect, in the way that matters most. Repairing
the frame count, I replaced nine with five — and five is the count on Python 3.14. On 3.13 it
is six, because pathlib splits read_text across _local.py and _abc.py and 3.14 collapsed it.
This project runs both right now: every fresh worktree and CI are 3.14, and the primary checkout
is the 3.13 outlier. So the correction was the same defect at higher precision — a number measured
once, on one machine, written down as a property of the thing rather than of the instrument. Both
numbers are in the comment now, each with its interpreter.
Worse, my own three attempts to measure it produced two invalid numbers before a valid one, and
each looked like an answer: the first ran a 3.13 interpreter that could not import pinakes and
reported one frame; the second ran the probe from a scratch directory, so its repo-relative
default resolved to nothing and it died in load() — six frames, right number, wrong traceback
entirely. A count is not a measurement until you have looked at what you counted.
And the same pass refuted a claim I had used to close a decision. I wrote that a golden set
which exists and cannot be read could not be pinned here, because chmod(0o000) is refused in this
repository and injection cannot cross the subprocess boundary _run_probe uses. The first half is
true. The second is false, and it took the reviewer ten lines to show it: _run_probe inherits this
process's environment, so PYTHONPATH pointing at a sitecustomize.py runs arbitrary code in the
child before the probe's first line. That is the same instrument tests/test_doctor.py:1497
prescribes, for the same stated purpose — what is under test is that an OSError out of the read
becomes a message, so raise one — reached through an env var instead of a monkeypatch.setattr.
The shape is worth more than the fix. An impossibility claim is the most expensive kind of unchecked assertion, because it does not merely state something false — it ends an inquiry, and it looks like diligence while doing it. I had written a fifteen-line comment justifying the gap, which made the gap look considered rather than untested; a reviewer who trusted the comment would have stopped where I stopped. The guard now has two arms and the second one is pinned. Before writing that something cannot be tested, spend ten minutes trying to test it — the sentence is a claim like any other, and it is the one nobody re-derives.
Re-running a measurement is not enough when there are two artifacts to run it against (20260901 06:33)¶
The rule that has caught every one of these is "re-run the measurement". It assumes there is one thing to re-run against. On the night of 20260831–0901 that assumption broke, and the break is worth more than the eight instances it sits at the end of.
Two sessions disputed a count. One had reported tools/agent_spend.py at 2 mentions; the
other counted 12. Both numbers were right — different selectors, one stated and one not. In
correcting the record, the first session asserted its table had carried the selector on every row.
It checked, and reported that it had.
It had checked the wrong artifact. The bash output it generated printed the selector on every row. The message it actually sent reformatted that and carried the selector once. Both artifacts were its own, both were to hand, and they differed in exactly the detail under dispute. The one it opened was the one that agreed with it.
So the mechanism is a substitution, not an omission — and that is what makes it new. The other seven instances that night were a population going missing, a denominator borrowed from next door, a qualifier lost between evidence and conclusion, a ref left unstated. Each is something absent. This one is the right kind of evidence, examined carefully, about the wrong object.
The practical lesson, which is the reason this is written down: "check the artifact rather than your memory of it" is an incomplete instruction, because it does not say which artifact. When several exist, the nearest to hand is the one that flatters. A check has to name its object before it runs, the same way a count has to name its selector.
It happened inside the correction. The claim was made in a message correcting another session's record of unstated selectors, in the sentence inviting that session to check the artifact rather than the memory of it, on the night whose entire subject was this failure family. The timestamps carry that without it needing to be argued.
What actually recovered it, both times, was a peer checking a claim that cut against itself. The second session verified the table, found it reproduced exactly, and struck its own sentence. The first session then re-checked its own correction and found the substitution. Neither was caught by a gate. Of the eight instances that night, exactly one was caught by its own author before it left a worktree — by running a grep before committing rather than after — and it cost one command.
The rule I quoted verbatim had been deleted a minute after I started (20260901 07:13)¶
HIGH — the file you are told is authoritative is a copy, and the copy has no clock on it.
I settled a disagreement with a peer by quoting the governing rule out of ~/.claude/CLAUDE.md. I
quoted it verbatim. I read the surrounding argument. I said which clause carved out my case and why
the peer's narrower reading was not what the sentence said. Every discipline this repository has
written down for quoting a source, I applied — and I was wrong, because the sentence had been
deleted 40 minutes earlier.
| My session started | 20260901 07:00 UTC |
~/.claude/CLAUDE.md last modified |
20260901 07:01 UTC (TZ=UTC stat -f "%Sm") |
| What my system prompt held | the text as of 07:00 |
| What was on disk | its replacement, ruling my case the other way, deliberately |
The injected CLAUDE.md in a session's context is a copy, and the copy has no knowable age. The
old text read "it does not cover a lone adversarial reviewer judging one finding on its merits" —
which reads as a per-finding carve-out, and I built a fan-out of eleven top-tier judges on it. The
user had replaced it that minute with a clause naming exactly that case and ruling against it: "a
lone reviewer ruling on a single finding on its own merits is a refuter, not the judge — then
one Opus pass over the refuters' collected verdicts." The new sentence exists because the old
one was ambiguous. I found the ambiguity and resolved it in the direction that had just been closed.
The first version of this entry said the copy "never refreshes". That is false, and the truth is worse. A peer holding the post-edit text in its own injected block corrected it, and both halves are checkable on this machine within the same hour:
| My session | The peer's session | |
|---|---|---|
| Injected copy holds | Audits and large analyses ALWAYS run on Sonnet 5 |
env.CLAUDE_CODE_SUBAGENT_MODEL … verified 20260901 07:00 UTC |
| That string on disk | no match — the heading is gone | present — it is the 07:01 replacement |
| Vintage | pre-edit | post-edit |
Same file, same machine, same hour, two live sessions holding different vintages, refreshed on a trigger neither could observe. Not "old session, old copy" — my start time did not tell me my copy was stale, and the peer's would not have told it that its copy was fresh. Nobody has isolated the trigger and this entry does not guess one.
This is not the relayed-claim failure this repo already has three instances of. Nothing was relayed; I read the primary source. It is one layer under that: the primary source I read was a cached copy of the primary source, of indeterminate age. The rule that falls out is narrow and cheap, and the reason is what the correction changes:
Before quoting
CLAUDE.mdto settle a dispute,grepit on disk — not because a long session is likely to be stale, but because no session can determine from the inside whether it is.
The peer caught it by grepping the file; I had not, because I believed I was already looking at it.
Two things I did right and one I did not. I stopped the run rather than let the fan-out spawn,
edited the stage out, and resumed from the run id — the eleven measure and eleven refute legs were
already explicit model: 'sonnet' and cached, so the cost was the eight legs in flight. I did not
check the file before asserting from it, and in the same message I raised a sound argument over an
unexamined domain against the peer's cost table — whose scope was stated in the message and one
grep from confirmable. Invoking the rule is not applying it, and I was the one not applying it
while naming it.
The resume geometry, since it is not obvious. Workflow resume caches on (prompt, opts), so
editing a late stage leaves earlier legs cached and free. But a stage gated by a parallel() barrier
after a pipeline() spawns milliseconds after the last leg returns — there is no window to "let the
fan-out finish, then kill before the next stage". With a barrier the choice is stop now and lose
what is in flight, or let it complete. Which of pipeline and parallel gates the stage decides
whether a zero-waste edit exists at all.
Three findings from peers this increment, verified here rather than taken on report. Each was handed over with a wrong cause attached, and in every case the conclusion survived and the explanation did not — which is the argument for re-running rather than re-reading.
- A suspiciously clean result is a reason to check the harness, not to write it down. A peer's 4×4 measurement matrix returned sixteen identical rows. Four programs with different argument parsers do not agree to the character; the uniformity was the tell, not the content. This is a positive test for harness breakage, and it sits beside the mutation rule this repo already has — a run with no kills is a broken harness, not a clean bill — discovered independently, in a different instrument.
- A null result from a selector you did not validate carries no information at all. Two
instances, one peer, one hour. First: it grepped for a sentence, got
0, and nearly reported landed work as unlanded — the sentence spans a source line-break, so the contiguous string does not exist even though the text does. Second: it greppedretro.d/for three phrases it remembered writing, got0, and was one keystroke from telling the user a landed fragment had been lost — the file is namedchecking-the-wrong-artifact.mdand was in the directory being searched. None of the remembered phrases survived into the committed text. What caught it was anlsin the same command block printing the filename; not the grep, and not a gate.
Distinct from the six refutations ruled against on 20260831: those failed on semantics (the string matched, the claim behind it differed); these fail on encoding (the claim was true, the string could not match). The operational form is testable where "read the surroundings" is not, and the second instance adds an edge to it:
Prove the selector can match something before trusting that it did not — and when searching for a document, list the directory before grepping its contents. A filename is a selector you did not choose, which is exactly what makes it useful.
The asymmetry is what makes this worth the entry — a false negative on did this land? invites
rebuilding landed work, and this repository has come within one message of that twice. The
substitution half of the same family — running the right measurement against the wrong artifact,
and why the nearest to hand is the one that flatters — is already recorded and is not restated
here, in the sibling fragment spliced just above this one: Re-running a measurement is not
enough when there are two artifacts to run it
against
(20260901 06:33). This was a code span when it was written, and the link was the correct form
even then — it resolves once both fragments are spliced, exactly as
docs/RETROSPECTIVES.md:4034 already does. tools/markdown_link_gate.py resolved a #… target
against the fragment's own headings, so writing it as a link turned main red at b6be317.
Restored here: the gate now resolves a fragment's targets from the document its body is spliced
into.
- uv run --frozen $cmd cannot work in this shell, and the error message hides why. zsh does
not word-split unquoted parameter expansions; bash does. Measured here: c2="python -c"; for w in
$c2 yields one word under zsh 5.9 and two under bash. So uv is handed a single argv
element named python3 -c and reports Failed to spawn: with the whole string as the command
name — which reads as that command failed rather than that is not a command. Build commands
as arrays or "$@", never as a string expanded unquoted. Not uv-specific; it is a trap for any
loop over commands on this machine.
The near-miss worth recording: that peer's first diagnosis was "python3 is not on the project
environment's path, only python is", and RESUME.md carries an instruction to use
uv run --frozen python3. Acting on the report would have deleted a correct instruction on the
strength of a quoting bug. uv run --frozen python3 -c "import pinakes" prints 0.31.1 in the
primary checkout and in a synced worktree alike. A wrong cause with a right conclusion is more
dangerous than a wrong conclusion, because the conclusion gets checked and the cause gets copied.
A suspiciously clean result is a reason to check the harness, not to write it down (20260901 07:15)¶
Sixteen runs agreed to the character, and not one of them had started. Measuring how four
programs handle a mistyped --questions, I built the commands in a shell variable and expanded it
unquoted: uv run --frozen $cmd --questions …. The result was a table of sixteen rows, every one
exit=2 Caused by: No such file or directory (os error 2). It reads as a finding — four tools
agreeing on a failure mode — and it is uv never having spawned an interpreter once.
This shell is zsh, and zsh does not word-split unquoted parameter expansions. c2="python -c";
for w in $c2; do … iterates once, over the single word python -c. So uv was handed one argv
element named after the whole command line and reported, accurately, that no such program exists.
Every run of mine that failed built its command in a string; every run that worked used "$@",
already split. Build commands as arrays or "$@" — never as a string expanded unquoted.
What caught it was the uniformity, not the content. Four programs with four argument parsers do not agree to the character; that is not agreement, it is a shared failure upstream of all four. This repository already has the negative form of the rule — a mutation run with no kills is a broken harness, not a clean bill — and this is the same shape in a different instrument. A result too clean to be interesting is evidence about the instrument.
The cost was not the wasted runs; it was the two wrong causes I published in between. First
that python3 is absent from the project environment and only python works — false, both import
pinakes fine in a synced worktree and in the primary checkout. Then, implicitly, that background
execution was to blame — also false, and both were plausible enough that a peer nearly deleted a
correct line from RESUME.md on the strength of the first. An invalid measurement produces a
confident wrong cause, and the wrong cause travels further than the measurement does, because it
is short, it is quotable, and nothing about it looks provisional. Both were caught by someone
re-running the thing rather than re-reading my sentence, which is now the fourth time this week.
And the corpus could not have caught the defect this increment fixes. retro.d/ holds only
unreleased fragments; the rest are spliced into docs/RETROSPECTIVES.md and deleted at each
release. When the gate was built the live directory held 4 files and all 4 passed, so a green
run over the corpus proved nothing — the only live counter-example anywhere was a fixture in
tests/test_fragments.py, which the new rule promptly turned red. A gate whose population is
"whatever is unreleased at this moment" cannot be validated by that population, and the census of
the historical fragments is a different denominator that must not be used to predict it.
The same shape again, one instrument along: a kill is not evidence until you check which
assertion did the killing. The battery for this gate reported 27 of 27 killed, and one of
those kills was false. The mutant was named "the stamp is compared by date alone, so the time may
be anything" and was written as clock = "", which makes wanted the string (20260901 :) — a
value that matches nothing, so the mutated gate refused every fragment, including correct
ones. It is the opposite of the laxity its name claims. It still died, and the report still printed
the name beside the test, so the battery carried a coverage claim for an axis nothing tested. The
faithful mutant was wanted = f"({day}" — and it stopped being faithful inside this same
increment, when the fourth pass anchored the comparison to the end of the line. wanted is
re.escaped, so (20260901 then had to be the heading's trailing token, matched nothing, and
refused every fragment exactly as its predecessor had, the control among them. Nobody re-measured
it, because it was still killed. The row now mutates the anchored comparison itself. A mutant
is faithful only against the code it currently runs against; a kill does not carry forward across
an edit to a line the mutant does not name.
A wrongly-credited kill is worse than a survivor. A survivor is loud — the report says SURVIVED and somebody looks. A kill is silent, it is what you wanted to see, and nobody rechecks a green row. This repository already holds the negative form of the rule, a mutation run with no kills is a broken harness rather than a clean bill; this is its inverse, and the two together say the same thing about every instrument: the output agreeing with you is not the check.
Two independent things caught it, and neither was a test. Reading the mutation report rather than
the code showed the substituted string, and an adversarial reviewer later reproduced it and found
the part I had missed — under that mutant the pre-existing control
test_the_real_documents_are_clean_which_is_this_checkers_only_control also fails, because the
repository's own correctly-stamped fragments are refused. So the kills attribution was not merely
mis-named, it was not even exclusive.
And a stamp read correctly off the clock can still be wrong by the time it lands. A peer's closure banner said 07:32 UTC — a true reading when written — and a session-limit stop landed the merge at 11:14, leaving a document naming a time no commit exists at. The clock rule stops you composing a number; it does not stop the world moving between the reading and the write. When a stamp is about when something landed, it can only be written after it lands, or it must name both times and the gap.
I read a runner's summary instead of the gate's own exit status — in the increment whose whole
subject is reading the thing itself. ./check.sh > log 2>&1; echo "CHECK_EXIT=$?" >> log, run in
the background. The harness reported completed (exit code 0): the wrapper's status, and the
wrapper ends in an echo, which always succeeds. I reported the branch green to a peer while
CHECK_EXIT=1 sat in my own tail output in the same message. The ; echo "$?" appended
specifically to preserve the status is what destroyed it — preserved in the file, discarded in the
wrapper. This repository already rules that a gate is only a gate when its exit status is what the
next command reads; the new edge is that wrapping a gate in order to record its status mints a
second, always-zero status for whoever is watching the wrapper. Read the log's last line, never a
runner's summary.
set -e makes a failing gate produce no result for every gate after it, and an absence reads as a
pass. check.sh stopped at the link gate, so overlap, nul-scan and template-drift never ran.
Anyone reading check.sh failed on the link gate concludes the rest was fine. It was unmeasured
— the same class as the wrapper above: a status nobody read, and an absence that looks like a pass.
Two agents mutating one checkout measure each other, and the run reports kills either way. The
first draft of this increment's review ran two source-mutating lenses concurrently in one worktree;
both apply a mutant to tools/fragments.py and restore it, and a restore by one is a false SURVIVED
for the other with nothing in the report able to tell. Rewritten before either finished: the
mutators serialized, the other lenses probing a copy through tools/fragments.py --repo <tmpdir>.
That is possible only because the tool takes its own root as an argument — a tool parameterised by
its repository root is testable by agents that must not touch the tree, which is worth knowing
while writing the next one.
Two selectors, no dispute to have — and both numbers wrong anyway. A peer measured five drifted
heading stamps shipping; I measured three; we agreed each was right under a different selector, and
neither of us had stated one. Re-measured over a population I can name — every retro.d/ path that ever
carried a YYYYMMDD_HHMM- prefix, each read at its own adding commit — the triple is six drifted
when written, three still wrong in the published document, and three fixed by hand between
fragment and splice (fbf17da wrote (20260826 07:31) and the shipped line reads (20260826 07:33);
a54b304, whose subject is "applying the new rule to my own fragments found three", makes exactly
those three stamp corrections — it changes a fourth file and appends thirty-eight lines of new
prose to one of the three above that file's own one-line stamp fix, neither of them a stamp, so it
touches four).
And the population needs a measurement point and a selector, because it has both and I wrote
neither. git rev-list <sha> --objects, deduplicated, paths matching
^retro\.d/\d{8}_\d{4}-.*\.md$: 111 at 56a970c and still 111 at this branch's tip
6cb80b4 — 107 at the branch point 8540e27, so the number moved inside this branch alone. Three
other defensible spellings of the same question, all at 6cb80b4: adding a -- retro.d pathspec
gives 102, because a pathspec turns on history simplification and prunes merge sides (adding
--full-history back restores 111, which is how that was confirmed rather than assumed);
git log --full-history --diff-filter=A gives 109, because a path introduced by a merge was
never added by any commit it walks; and plain git log --diff-filter=A gives 100, losing
both. Four selectors, spread eleven apart, and the sentence above named none of them until now. The arithmetic was the tell we both walked past: five minus two
is not three, and six minus three is.
State the selector even when the other party agrees with you: agreement between two unstated
populations is a coincidence, not a confirmation.
The general shape of the defect this gate sits beside. This gate reads a relation whose both
operands are inside its input — the heading against the filename, one file, nothing else consulted.
tools/markdown_link_gate.py reads one operand of a relation whose other operand lives in the
destination document. A gate holding half a relation can always be wrong about a form that is
correct where it lands, and it is wrong in both directions: green on the branch and broken after
splicing, or red on the branch and correct after. One missing operand, two signs. The fix is not a
new rule for that gate; it is handing it the operand it never had.
Build the instrument that can call a fan-out wrong before you read what it found (20260901 07:36)¶
A fan-out re-measured 34 open documentation findings and proposed 2 closures. I could not have judged those 2 from their own text — every closure arrives with a named commit and a passing grep, which is exactly what a wrong closure also looks like. So before reading any of them I measured something the fan-out could not influence: how much each audited file had changed since the audit's own baseline.
| File | Commits since c45ffa8 |
Findings | Closed |
|---|---|---|---|
docs/VERIFICATION.md |
42 | 2 | 2 |
docs/CLI.md · docs/KB-UPDATES.md |
4 | 9 · 4 | 0 |
docs/GUIDE.md · docs/DESIGN.md |
2 · 1 | 5 · 6 | 0 |
MANIFEST · MEASUREMENT-RUN · both READMEs |
0 | 9 | 0 |
Both closures landed in the single most-churned file, and every byte-identical file closed
nothing. A prior built before the verdict, from git rather than from the findings, turned two
unfalsifiable claims into two predicted ones. Had a closure come back for docs/MANIFEST.md —
0 commits, byte-identical — it would have had to explain how a document nobody edited stopped
being wrong, and no per-finding judge could have asked that: each sees one finding, and the rate is
the thing no single finding contains.
The cheap general form: when delegated work returns a verdict you cannot check directly, find a
second measurement of the same population that the delegates did not produce, and check the two
against each other. Here it cost one git log --oneline <range> -- <file> | wc -l per file.
Three smaller things, all self-inflicted, all caught by measuring.
- 35 findings, 35 refuter agreements, zero disagreement — and it was fine. My own rule from an hour earlier says a suspiciously clean result is a reason to check the harness. I checked. The refuters had run 263 commands between them and their reasoning showed independent re-reproduction. The uniformity was real agreement, which is what the rule is for: it says check, not disbelieve. A clean result that survives the check is stronger than one nobody questioned.
- My check for "did the refuters do any work?" returned zero, and the zero was mine. I summed
the length of each verdict's
evidencefield; the schema names that fieldwhy. Every length was 0 and I was one inference from reporting a rubber-stamp harness. What caught it was printing the key list beside the values in the same command. This is the null-selector rule, hit within the hour by the agent that had just written it down — and the operational half held: printing the keys is thelsbefore the grep. make docs 2>&1 | tail -15thenecho $?readstail. Twice in one session. The repo's own rule says run a gate bare. Both times the real status happened to be 0, so nothing broke and nothing would have told me if it had. A gate misread as green is indistinguishable from a gate that is green — which is the entire reason the rule exists, and no reason at all to relax it.
A number can be wrong in two places for weeks while three routes to it all agree. This audit
recorded 39 findings in a heading and a table cell. Counting says 40, and so do the
# Medium — 13 / # Low — 27 dividers, and so does 34 open + 6 closed. A correction filed
20260825 had already reasoned it out — "the 34 is right and the 39 is wrong" — and the two cells
still said 39 today, because the correction was filed in a third register and nothing read it back.
Filing a correction is not making one, and the register that receives a correction is the least
likely place anyone will look for it.
The rule that falls out is narrower than "remove counts", and a peer sharpened it while this was being written. Landing beside me, it checked its own new rows in the same file against my decision and found they were exempt — every digit in them belonged to one dated measurement rather than to the tree:
A count of what is in the tree today decays silently; a dated measurement of something that happened once does not, because the tree changing cannot make it false. Take out the first kind. The second kind is why a record can carry numbers at all.
That distinction is the difference between test_eval.py carries 32 rows — false within weeks of
being written, twice — and three headings out by 1 minute, 2 minutes and 3 hours 30 minutes on
20260826, which will be true forever. Both are numbers in a document; only one of them is a
claim about now.
When a gate and a convention disagree, the shipped artifact is the tiebreaker — and it costs one grep (20260901 11:25)¶
main went red on a link I wrote. The gate said the anchor did not exist; the gate's own error
message said to put it in a code span. Two peers reproduced it independently, diagnosed it
identically, and both recommended the same fix. Everything pointed one way: my fragment was
wrong, correct it.
It was not wrong. retro.d/README.md:40-48 instructs fragment authors to write exactly that form,
and one grep settled which of the two was defective:
$ grep -c '](#[a-z0-9_-]*-20[0-9]\{6\}-[0-9]\{4\})' docs/RETROSPECTIVES.md
2
$ sed -n '4034p' docs/RETROSPECTIVES.md # links to the heading at :3945
([*measured the launcher, not the work*](#measure_sync_cpupy-measured-the-launcher-not-the-work-20260805-1737))
The form is live in the published document and mkdocs --strict passes over it. So the link was
right about where it was going, and the gate is half-built: tools/markdown_link_gate.py models a
fragment's disappearance at splice time (:296-305, because --apply deletes it) and not its
destination. The fix I applied is still the code span — main was blocking three branches — but it
is now recorded as a temporary degradation of a correct link, with a build-order row to remove
it, rather than as a correction. Those are different entries in the register, and only one of them
gets undone later.
Two things to keep.
A gate is evidence about the tree, not about the convention. When a checker and a documented instruction contradict each other, at least one is a defect and the checker is not automatically the survivor. The tiebreaker is the artifact the convention exists to produce — here, the published document, where the disputed form already worked. That is a grep, not an argument, and it is available before any of the reasoning starts.
Two peers agreeing is not two independent checks when the error message framed them both. Both reproduced the failure faithfully and neither asked whether the form already shipped, because the gate's message had already named the fragment as the thing to fix. Convergence measured agreement with the framing, not with the tree. The question that separated them from it — does this form already work somewhere? — was not a better inference; it was a different population, and nobody had named one. It is the sibling of a null result carries no information until the selector is shown able to fire: a shared premise makes independent agents into one agent.
Related, and the reason this fragment exists at all: the same landing found that four of the eight
rows parked in plans/20260825_1240-run-pinakes-sweep.md were already done — a register whose
stated purpose is to stop work ageing, ageing at 50%. Every remaining row now carries a command that
says whether it is still live, because a row is a claim about the tree on the day it was written and
nothing re-checks it.
A register decays at the speed of its neglect, and the rate is measurable (20260901 11:40)¶
Two registers live in one file. I measured both against the tree on the same morning, with the same method — one agent per open row, plus a spot-check of two verdicts by hand.
| Register | Rows checked | Stale | Rate |
|---|---|---|---|
## Build order — the table an implementer opens to pick up work |
7 | 1 | 14% |
| Decided work with an owner and no build order — the parked table | 8 | 4 | 50% |
Same file, same author, same discipline, three and a half times the rot. The variable is not care; it is readership. A stale row in the build order meets a reader who can falsify it, because that table is what somebody opens when they want work. The parked table's stated purpose is that nobody has to read it — and half of it described finished work, two rows of it finished before the table was written.
The one stale build-order row is the more interesting half. Row 3 said the per-thread connection in
pnk serve was still to do; e526e29 had landed it nine days earlier, with five tests, one of them
asserting the row's own symptom. It was found by a peer reading src/. No gate saw it, and no gate
can: a row is a claim about the tree on the day it was written, and nothing re-reads it.
So the two fixes are different, deliberately. The parked table got a liveness command per row —
one grep that says whether the row is still live, because the cost of the check is a minute and the
cost of skipping it is rebuilding landed work. The build order got a dated measurement per row,
with the citation that settles it. That distinction is a peer's and it is worth keeping exactly as
they put it: a count of what is in the tree today decays silently; a dated measurement of something
that happened once does not, because the tree changing cannot make it false. A row saying "open"
rots. A row saying "measured still open, 20260901 11:35 UTC, sync.py:693 catches PinakesError
only" cannot — it stays true about that morning, and its staleness becomes visible rather than silent.
The general form: a register nobody reads is not a register, it is a backlog with better punctuation. If a list exists so that work is not forgotten, and the list is never read back, then the list has become the thing that forgets. Either give it a reader or give each row a check that a reader would have run.
I typed the counterevidence myself, then named the wrong mechanism anyway (20260901 11:41)¶
Both published documents in the primary checkout were truncated to zero and every retro.d/ fragment
deleted, README.md included. I measured it, checked no process was still writing, backed up what was
untracked, restored from HEAD, and verified. That part was right.
Then I explained it, and the explanation was wrong. I said a peer's in-flight test had written a
fixture into the real retro.d/ and run the splice against the real repo root — a test-isolation
defect in their code. I wrote it into the build order as a work row assigned to them, and sent it to a
second peer who publishes a document that updates in place.
The refutation was already in my own message. I had written, to argue this was not a normal
splice: "a real --apply adds content and never touches the README." Both halves are true, and both
halves kill the theory I went on to state in the same paragraph. One grep confirms it:
tools/fragments.py:121 fragments_of ... if p.name != "README.md" -> a consume step cannot delete it
tools/fragments.py:590 (repo / stream.target).write_text(spliced) -> --apply GROWS the target
tests/test_fragments.py:40 def run(repo: Path, ...) -> `repo` is a required positional; there is
no default to forget, so the route I was guarding does not exist
The actual cause was one agent, one block, one failed cd — four newline-separated shell lines in
a probe: a cd into a directory that did not exist, followed by rm -f retro.d/*.md and : > on both
targets, the pair of them twice in the one run. The cd failed and stopped nothing, so all of it ran
in the session's cwd, which was the repository.
That sentence is the second version of this paragraph, and the first was worse than the error it
replaced. Told that a second agent had run the same recipe six times three minutes earlier, I ran a
census over every transcript, found six more rm -f retro.d/*.md calls, and rewrote the cause as two
agents authoring one symptom each. A peer then read the tool results — which neither of us had
opened — and the account collapsed: all six of the other agent's runs succeeded inside their probe
directory, the first result opening no matches found: retro.d/*.md because that directory was empty.
It deleted nothing. The one block whose result reads (eval):cd:1: no such file or directory is the
only one that ran anywhere near the repository, and it is the one that did all of the damage.
A command's text is not evidence that it ran where you think it ran. That is the lesson, and it
generalises past this recipe in a way that nothing about cd does. My census was one layer short of
right: it correctly separated the executions from the two of us grepping for the string during the
investigation — eight of the sixteen recorded calls are the investigation — and then treated all eight
executions as equivalent, because I was still reading intent. The result is the event. Pair each
tool_use with its tool_result on id and read the first line; it costs the same query.
And the argument I did check was one that could not have distinguished the cases. I reasoned that
the surviving backup held two-spaces-after-hash.md and no leading-tab.md, so the later block must
have run elsewhere — true, and I was right about that block. But the same evidence is equally
explained by the damaging block's own second rm -f deleting the file its first half had written,
which is what actually happened. I took a correct conclusion from an argument with no discriminating
power and counted it as verification.
The failure is not that I guessed. It is that I reasoned from symptoms to a named mechanism in
someone else's file without opening the file, while holding — and having already stated — the fact
that refuted it. An hour earlier I had written a fragment congratulating myself for running exactly
this grep against docs/RETROSPECTIVES.md before deciding which side of a contradiction was
defective. I did not run it against fragments.py.
Two things to keep.
A fact you state in passing is not a fact you have used, and the test is mechanical: would the conclusion survive if that sentence were true? Mine would not have. The counterevidence was not missing, or buried, or expensive — it was in my own outgoing sentence, deployed for a smaller purpose, and never turned on the larger claim. When a paragraph contains both "X cannot do this" and "X did this", the paragraph is the alarm; nothing else has to fire. The testable form is a peer's, and it beats "be more careful" precisely because it can be run against a draft.
That peer also drew the distinction I would have missed about my own error: this is not the failure of not looking. I looked, wrote down what I found, and then reached past it for a cause that fit the symptoms. It is the harder one to catch, because the usual remedy — go and read the file — has already been performed. Three of us reasoned from symptom to named mechanism on this incident, and only an agent transcript settled it. The missing instrument was not a gate.
A diagnosis that assigns work to another agent's file is a claim about that file, so open it. Naming a mechanism converts a symptom into a task with an owner, and the owner then has to spend a message refuting it. The bar for "I think this is what happened" and for "row 15, owner: coder" is not the same bar, and I used the first to write the second. The row is removed; the correction cost two peers a round trip each, which is the cheap version of this mistake.
Two of us searched for how a file was deleted; it had never been deleted (20260901 11:53)¶
A fixture written into the real retro.d/ by a runaway probe was gone by the time anyone looked for
it. A peer went after the mechanism properly: grep -r 'git clean' across every *.jsonl under
the project's session directory, then grep -r for any rm naming the file. Both returned nothing.
They reported it honestly — "an untracked file with no git record and no rm on record is exactly
the class of event that leaves no evidence, which is itself the finding" — and I had reached the same
dead end from the other side.
The file was in my own scratchpad. I had moved it there during recovery, when I backed up what was untracked, and neither of us thought to look in the place the recovery notes said it went.
The error is not that we missed a directory. It is what we did with a null result. The standing
rule here is that a null result carries no information until the selector is shown able to fire — and
these selectors had fired: the same greps, over the same files, returned a583's six rm -f
retro.d/*.md calls and acea's two. The instrument was proven on the run that produced the zero. So
the zero was not an absence of evidence; it was evidence, and what it said was the premise is
wrong — nothing deleted the file because the file was never deleted.
That is the half of the rule I had never written down. Once the selector is proven, a null stops being a gap and becomes a finding, and the finding is usually about the question rather than the world. We each kept the question ("what deleted it?") and spent the result on doubting our coverage.
The tell was available and cheap. Both of us had already searched for the deletion; neither of us
had searched for the file. One find over the enclosing directories would have ended it in seconds,
and find is the query that does not presuppose the event. When a mechanism search comes back empty on
a proven instrument, the next query should drop the mechanism, not widen it — ask where the thing is,
not what removed it.
Fourth time on this one incident that a symptom was matched to a named mechanism before the premise was checked, and the first where the unchecked premise was mine.
An options table can be honest, complete, and wrong about the question (20260901 12:00)¶
A survey session put a process decision to the user: freeze the register layer, freeze nothing, or consolidate. Four columns, real pros and cons for each, costs, reversibility, a marked recommendation, and the author volunteered that they benefited from the recommendation being adopted. By this repository's rule for putting a decision to a user, it was correct work — and it was the best-argued option table I have been handed here.
All three options shared a premise none of them stated. The evidence was a ratio: over eight days
src/ took 5.5% of inserted lines while plans/ grew 44%. Every option treats that as a measurement
of appetite for process — too much, acceptable, or a thing to consolidate. It may instead be a
measurement of queue position. Over that same window the code work was decided-but-unstarted and
the document work was unblocked, and agents write what is available to write. If that is what
happened, freezing the register layer does not cause the unbuilt defects to be built; only building
them does, and the ratio corrects itself when the queue drains.
That reading is not a fourth option. It is an argument that the question does not need answering yet, and it cannot appear as a row in a table whose rows are all answers.
What generalises. A pros-and-cons table is a strong instrument for choosing between framings and a weak one for noticing you are inside one — every option inherits the framing, so the framing is the one thing the comparison cannot test. The check that works is cheap and does not require a fourth column: take the evidence and ask what else it would look like this if it were true. Here the evidence was a ratio, and a stalled queue produces the same ratio as an appetite for process.
It is the same failure the rest of today was made of, one level up. A symptom was matched to a named mechanism — a ratio to a cause — and the alternative mechanism was never enumerated. The difference is only that this time the reasoning was laid out in a table with its costs priced, which made it look like the enumeration had already happened.
I added the reading to the file and left the recommendation standing, because the decision is the user's and a third reading is information rather than a ruling. Recording the conflict of interest in the document, not only in the handover message, is part of that: the person choosing should see who benefits from the recommendation without having to read the correspondence.
A watcher outlived the session that armed it, and reported green on a red build (20260901 12:26)¶
A monitor fired into this session saying CI 9cc9bc1: success. The CI workflow on 9cc9bc1
concluded failure. The event was not wrong about a different sha and not stale — it named the
sha under discussion and asserted the opposite of the truth.
It had no owner. Its task belonged to the planner session this one replaced. That session's context was cleared; its monitors were not. They keep running, keep matching, and keep emitting into whoever occupies the project next — with nobody left who knows what filter was armed or why.
That is worse than a stale document, and the reason is about how each is read. A document is dated, and a reader discounts it. An event arrives looking current, and a watcher's whole purpose is to be believed without being re-checked. Every other disputed claim today was settled by running a command; this one arrived pre-packaged as the output of one.
Then two agents got the attribution wrong in opposite directions, and one cat settled it.
| Claim | Actual | |
|---|---|---|
| Me | "your watcher reported a false green" | Not theirs |
| The peer | "that was my watcher, mislabelled — a true statement about docs read as CI" |
Their two watchers said exactly what they said; the event came from a third session |
Their defence of their own instrument was correct and my accusation was not. Their inference that the event must therefore be theirs was also not. Neither of us opened the file before asserting, and the file is three lines long. It is the day's shape for the sixth time: a symptom matched to a named mechanism before the premise was checked.
What the file cannot tell us, stated because the temptation is to close the story. CI <sha>:
success does not reveal which defect produced it — a match on the first completed run (the
docs workflow) mislabelled, or a read of the CI workflow with the wrong field. Those are different
bugs. Without that session's monitor command the mechanism stays open, and the checkable part is
enough: the file says success, the workflow failed, and no live session can correct it.
One more, caught before it landed rather than after. Writing the fix for a register that decayed
because its measurement was unpinned, I stamped my own replacement measurement 12:26 UTC — a
minute that had not happened yet. I caught it by reading the clock before writing the fragment, re-ran
the measurement, and took the stamp from the same invocation as the command. The values were
unchanged, so nothing shipped wrong; the process was still the exact failure the edit was repairing.
Reading the clock and reading it at the moment of the measurement are different disciplines, and
only the second one survives being audited.
And the run that failed is worth separating from the failure. upload-artifact timed out
against GitHub's own service; every test and gate passed. But eval-cross-machine-compare then
skipped, so for the length of attempt 1 cross-machine eval determinism was unverified at that
sha — not failed, unverified. Had the upload succeeded and the comparison failed, the summary would
have looked identical. A gate that did not run is not a green gate, and a re-run can clear the
red X while leaving the same hole.
It did not, here: attempt 2 concluded success with all 15 jobs green and
eval-cross-machine-compare ran rather than skipping — checked against the jobs API, because the
run's own conclusion is the number that would have hidden the skip in the first place. The
distinction still cost nothing and bought the only question worth asking about a re-run, which is
not did the red X go away but did the job that was missing execute. And it does not carry
forward: both are properties of 9cc9bc1, not of the day, so a later sha re-opens both.
A section number is only meaningful inside its own document (20260901 18:26)¶
A cross-document reference lost its document and kept its number. docs/README.md described
what KB-UPDATES.md still leaves unbuilt as "the rest of §8's shape — pnk adopt".
KB-UPDATES.md §8 is Open questions, and it has never contained pnk adopt — not in the
current file and not in any commit, which git log -S settles in one command. The §8 that
proposes the command is docs/graph/PINAKES_APPROACH.md's, and the reason the number travelled
is visible in that file: its release-mapping table has a From column whose cells read §3,
§8, R1 R6 — its own sections. Someone read the template release ⚠️ |pnk adopt… | §8
and carried §8 into a sentence about a different note. The bare §N form is what made it
portable. A reference that had read PINAKES_APPROACH §8 could not have been misfiled.
Two registers agreed with each other and neither was checked against the tree. The dangling
§8 had been repeated between docs/README.md and my own draft rewrite of the
KB-UPDATES.md header — I was about to propagate it into a second file, sourced from the first,
because the first was a document I trust. What broke the loop was not scepticism about the claim.
It was checking whether the string was in the file at all: grep -n 'pnk adopt' docs/KB-UPDATES.md
returned only the line I had just written. The selector was proven able to fire — the same
pattern across the repo returns eight other hits — so the empty result was a result.
And the header that prompted all this failed by accretion, not by error. Every clause in
KB-UPDATES.md's status header was true when it was written; four releases each appended one, and
the reader meets them in the order they were added rather than the order they matter. The final
clause said --apply remains a proposal; it had shipped two releases before, and §1 of the same
file cited "the header above" as agreeing with it. An append-only status line degrades even
when nobody writes anything false into it — which is the case for rewriting to the current state
rather than layering a correction, and the reason that convention exists.
A gate spoke correctly about an input nobody had edited (20260901 18:40)¶
MEDIUM — ./check.sh went red on a branch that had touched nothing near the failing gate, and
the cause was not in the source tree at all. The repository's standing rule is assume other
agents run concurrently, and the failure mode it names is a clean-but-wrong merge. This is one
layer below that. Nothing was merged. Nothing was edited. git status was clean throughout. What
changed was which tree the worktree's virtualenv resolved pinakes to.
The facts, which are two runs on one tree an hour apart.
| Earlier run, nothing else touching the worktree | 2420 passed, 4 skipped, no failures |
| Later run, six review agents working against the same worktree | 1 failed, 2419 passed, CHECK_EXIT=1 |
| The failure | test_the_real_package_is_refused_from_the_source_tree expected "resolved to the source tree" |
| What it got | wheel-import: pinakes resolved to an unpacked tree at /private/tmp/claude-501/… — a scratchpad path |
| Re-run alone, minutes later | 23 passed; the gate invoked directly names this worktree's own src |
| The venv | two .pth files, the editable one pointing at this worktree's src, by then correct |
The inference, which is an inference. Between the two runs the venv's editable pinakes install
pointed at a copy of the tree in a scratchpad, and uv run --frozen put it back. Six agents were
running against that worktree under an instruction to copy it into their own scratch directory
before experimenting. Which one, and by what command, is not established, and no agent is named
here — the honest limit of the evidence is that six were running under that instruction.
The repair was visible, and that is the sharper half. uv run --frozen printed Uninstalled 1
package … Installed 1 package above the test output before the tests ran. So the log held both
the damage and its cure, in order, and a reader scrolling to the failure would have found a red gate
a branch that never went near it. A silent repair leaves a mystery; a visible one leaves something
worse — a plausible wrong answer, in the log, above the evidence against it. The line that
explains a failure is often printed before the failure, where nobody is looking yet.
The rule. A red ./check.sh is not evidence until you have re-run it with nothing else
touching the tree. The same holds for a green one: a run made while agents work in the same
worktree is measuring a tree that was not the tree at any single moment. Landing on such a run is
landing on a measurement of something that no longer exists.
Why git status cannot catch this class. Every guard this repository has for concurrency reads
tracked files — tools/shared_file_overlap.py compares file sets, a monitor watches the primary
checkout going dirty, land.py refuses a worktree holding an untracked file. .venv/ is
gitignored, so the one piece of state deciding what import pinakes means is invisible to all of
them. The seam is not the source; it is everything the source is resolved through.
What it cost and what it nearly cost. Twenty minutes, and one report to a peer that would have read as this branch broke the wheel-import gate. It did not go out that way, because the first move was to re-run the single file rather than to explain the failure — the same discipline as reading a gate's own exit status instead of a runner's summary, applied to a gate whose input rather than whose status had been substituted.
git merge-base is not the branch point after a rebase (20260901 20:03)¶
Four agents in one review pass raised the same false finding, and every command each of them ran
was correct. A comment in a mutation battery said the live retro.d/ directory "was 4 files when
this was built and all 4 already passed". Two independent lenses reported it false; two independent
refuters, told to refute, each confirmed it false instead. One went further and re-ran the gate at
the merge-base to produce a measured count of 12. The sentence was true.
The branch had been rebased. git merge-base HEAD origin/main returned 8540e27, and everyone
read that as the branch point. The reflog says otherwise:
…@{8}: branch: Created from main -> 0aea036
…@{2}: rebase (finish): … onto 8540e27be…
0fd4a86, the commit that wrote the sentence, has author date 2026-09-01T08:19. 8540e27 has
author date 19:00 — eleven hours later. The merge-base was a commit that did not exist when
the sentence was written, and eight retro fragments landed on main during those eleven hours. At
0aea036, retro.d/ holds exactly four fragments and the gate passes all four.
A rebase moves the merge-base forward and leaves no trace in the commit graph. After it, the
merge-base names where the branch was replayed onto, never where its work began. Any claim of the
form "when this was written, the tree held X" must be measured at the reflog's branch: Created
from, or at the authoring commit's own parent — never at merge-base.
What makes this worth recording is not the wrong number, it is the four-agent agreement. The finder and the refuter are supposed to be independent, and they were: they ran different commands in different orders. They agreed because they shared a premise nobody stated — that merge-base is the branch point — and no amount of independent re-measurement can catch an error in the population you both selected. This repository already carries the rule: a claim resting on a set you selected or an instrument you chose must state the selector. Here the reviewers failed it and the author had not: the sentence names its own selector, "when this was built", and the reviewers substituted a different one.
The cheap defence is to make the selector executable. The sentence now reads with its sha in the neighbouring comment, so the next reader is told which tree to measure rather than left to infer it.
I broke the table I was adding a row to, twice in one day (20260901 20:36)¶
A row appended before the right anchor is not in the table. I inserted two rows immediately before the paragraph that follows the § 3 build order — which put them after the blank line that ends the table. Markdown then reads them as a second, headerless table. The rows were correct, the cell count was correct, every pipe was escaped, and the result rendered as two tables where the build order is supposed to be one.
What caught it was counting, not reading. awk over the section printed a line number for
every table row: …168, 169, 171, 172. The gap at 170 is the whole finding. Reading the file would
not have shown it — a blank line between two table rows looks like nothing, and both halves render.
The check that works is is the sequence contiguous, which is a property of the sequence and not of
any row, and every check I habitually run reads rows.
That is the second table-structure defect here today and the first had the same shape: an
unescaped | inside a code span in a measurement row, where GFM splits cells before inline
parsing, so a pipe inside backticks still ends a cell. Both are invisible to a reader, invisible to
the link gate, invisible to mkdocs build --strict, and fatal to the thing the table is for.
Nothing in this repository checks that a table is well-formed — not check.sh, not CI. The
counting one-liner is not a gate; it is what I happened to run.
And the file this happened in is the plan about registers decaying. Its own § 0 note records that a register decayed inside the plan about registers decaying, within four hours of landing. This is the same file, six hours later, decaying structurally rather than numerically while I added a row ruling how careful other people should be with headings.
Postscript, same hour, worse. Ruling on that table's row 15 I told the coder to write
unreleased, 20260823 · into a battery section. The gate it describes shipped in 0.30.0 — the
coder checked, refused the instruction, and gave me the ancestry. I re-derived it rather than accept
it: v0.30.0 is the earliest tag containing 6d7c9e3, and v0.29.0 is not, so the selector
discriminates. My instruction would have written unreleased over a released property in the one
file whose job is recording which release shipped which property.
I had the convention in front of me and read the wrong half of it. tools/batteries/README.md
says sections name the release that shipped the property, and only unshipped work says
unreleased, YYYYMMDD. The bare date 20260823 is raw material for both spellings, and it reads as
unreleased-ish precisely because the unreleased form is the one that carries a date — the version
form carries none. That is the coder's observation, and it is the useful part.
The rule I take from it: a non-conformance does not tell you which conforming form it should
have been. I inferred the target from the shape of the defect instead of from the fact — and the
fact was one git merge-base --is-ancestor away, in a repository whose standing rule is that a
peer's claim is not evidence until checked. The peer checked mine.
A clean result from an instrument that never ran (20260902 00:04)¶
Twice within twenty minutes, two unrelated instruments returned a clean result over a population neither had reached.
The first was this increment's eighth adversarial pass. It returned verdict: CLEAN, findings: [].
Its five lenses had all been killed by a session limit before doing any work: agents_done: 0,
agents_error: 5. The verdict was not wrong about its input — my own script returns CLEAN when the
finding list is empty, and the list was empty. It was wrong about what an empty list meant. Passes
5, 6 and 7 had each found a false sentence, so this CLEAN would have ended the loop. Reviewing the
same commit by hand instead found two more false sentences, both written by me in the pass before.
The second was a probe I wrote minutes later, asking how many retro.d/ fragments have ever carried
a second ## heading. It read each fragment at the last commit that touched it. For a fragment
consumed into docs/RETROSPECTIVES.md at a release, the last commit that touched it is the one that
deleted it, where git show <commit>:<path> yields nothing — so it examined 15 of 127 and
skipped 112 in silence. It reported 0 of 127. Reading each at the newest commit where the file still
existed reports 2 of 127, and those two turned out to be exactly the evidence a peer's open
question needed.
And that denominator names no measurement point, so writing it down falsified it. 127 is the
count reachable from 2914b09. The commit that recorded 2 of 127 added the 128th path, and the
path it added was this file. Pinned: 127 at 2914b09, 128 at 04497a9 and at this branch's
tip, 129 at origin/main today, 133 under git rev-list --all --objects — which counts whatever
every ref happens to hold and so moves under other people's commits, not just your own. State the
sha. The numerator never moved: the same two fragments at every point measured.
The standing rule here covers the step after this one. It says a null result carries no
information until the selector is shown able to fire, and that once the selector is proven a null
becomes a finding. Both selectors here were fine. grepping for a second heading works; an
adversarial lens works. What neither run established is that the instrument ever reached its
population — a different question from whether it can fire, and the one that failed.
So the number to demand is the denominator, and it is never the default output. agents_done
against agent_count; examined and skipped against the list you started from. I had to add both
by hand, and in both cases the honest count is what exposed the result. Until an instrument says how
many items it actually looked at, it has not reported a result — because CLEAN, 0 findings and
0 of 127 are precisely what a real clean run looks like, and nothing in either output distinguishes
the two.
I measured the live directory and answered about the corpus (20260902 00:07)¶
A ruling needed to know how often a retro fragment carries a second ## heading. I counted the
live directory — 0 of 14 — and told a peer the question was "entirely prospective". The number
was right. The sentence was about a different population, and it was wrong: across every fragment
that has ever existed there are two, and one of them is the case the ruling turns on.
retro.d/ is a consuming directory. Every release splices its fragments into
docs/RETROSPECTIVES.md and deletes them, so the live directory is not a sample of the corpus —
it is the handful written since the last release. Counting it and generalising is not a small
extrapolation from a large sample; it is a claim about 129 things made by looking at 14. The peer
found the two, and had it not, the ruling would have been justified by a population that excluded
its own counterexample.
Then the correction repeated the mistake one level down. Asked to check the full history, I
walked git log per path and read each fragment at the newest commit that touched it. For a
consumed fragment that commit is the one that deleted it, so git show returns empty. My probe
reported "0 commits touched" for six paths — a self-contradiction, since those paths came out of the
log — and I only noticed because my total disagreed with the peer's. Their probe had the same defect
and skipped 115 of 127 silently. Two independent probes, the same blind spot, because it is a
property of the directory and not of either implementation.
What settled it was changing instrument rather than fixing the query: git rev-list --all --objects
enumerates every blob ever reachable and git cat-file reads one by hash. There is no path-to-commit
lookup, so there is nothing to get wrong — 129 paths, 239 distinct versions, unreadable: 0, and
the count fires twice. A probe that cannot report what it failed to read is worth less than one that
reads less and says so.
The last step was the one that nearly went wrong in the other direction. Two instances looked like enough to weaken the ruling, since the second fragment stamps neither heading. The obvious move was to set it aside as pre-convention — and that is false: 10 of 126 fragments have an unstamped first heading, scattered to the day before this was written. Stamping never became universal, so there is no cohort boundary to hide behind. It is ordinary non-compliance, it licenses nothing about second headings, and the ruling stands at n=1. The reason it stands is not the reason I first had.
The rule. Before generalising a count, name the population the number came from and ask whether the directory deletes. Where a probe can silently skip, prefer the instrument with no lookup to get wrong — and make it report what it could not read, because a zero and an unreachable set look identical in the output.
I read an error message as evidence of the arm that raised it (20260902 02:04)¶
I ruled that closing a hole in a fragment gate was not new process, because "the sibling stream
already gates this". The evidence was three filenames through changelog.d: the canonical one
passed, a typo'd prefix and a prefix-less one were both refused, and the refusal said "filename
must be YYYYMMDD_HHMM-<category>-<slug>.md". Prefix gate, plainly.
There is no prefix gate. It is a category gate, and one more fixture separates them:
fixed-a-thing.md — no prefix at all, but its head is a category — is accepted, while
banana-a-thing.md is refused. A malformed prefix is refused there only incidentally, by shifting
the first hyphen-separated token out of the six allowed categories. The message names the whole
convention because the convention is what the author wanted the reader to go and read; it is not a
statement about which arm fired.
Two refusals confirmed a mechanism that one more row would have falsified, and the missing row was cheap and obvious in hindsight: hold "prefix-less" fixed and vary only whether the head is a category. I varied two things at once in every fixture I wrote, so no comparison in the set could isolate either.
I have a rule that a null result proves nothing until the selector is shown able to fire, and I applied it that same hour to someone else's work. This is its mirror and I did not recognise it: a refusal proves nothing about which arm refused. A firing selector and a firing gate are the same problem — output that is consistent with the hypothesis is not evidence for it unless something in the set could have come out the other way.
The peer who found it did the thing I had not: constructed the case where the two hypotheses disagree, rather than adding another case where they agree.
And a second slip in the same edit, from the same family. I wrote 20260902 00:35 into three
places, composed from my previous clock read rather than taken from the clock. The machine had slept
about ninety minutes; the real time was 02:04. Every stamp I write carries a rule saying read the
clock, never compose it — and the failure mode is not forgetting the rule, it is believing you
already know the answer well enough not to check. Which is exactly what the error message did to me
one paragraph up.
The rule. When output is consistent with what you expect, ask what the other hypothesis would have produced, and go and build that case. If nothing in your fixture set could have come out the other way, you have collected agreement, not evidence.
A method is not a measurement point, and --all is not a corpus (20260902 02:45)¶
I corrected a count by naming a better instrument and thought that finished the job. It did not.
The first version of row 14 said 116 fragments, exactly one, measured over the live directory and
stated as a fact about the corpus. The correction replaced it with 129 paths, 239 versions, taken
from the object store — the right instrument, and the row said so: git rev-list --all --objects.
Re-checking my own branch before landing it, that number reproduced against nothing. --all is not a
corpus. It is every ref any branch happens to hold at the instant it runs, so it counted unlanded
work on my branch and on a peer's, and it had drifted to 132 paths / 243 versions inside an hour —
without a single fragment being written to main.
The tell was visible in the row and I had not looked: three denominators for one directory. Row 14
said 129 and, four sentences later, 10 of 126. Row 17 said 131. One of those was all-refs, one was
main, one was a moment. A row whose whole argument is that a count must state its population was
carrying a numerator and a denominator drawn from two different ones.
Pinned to origin/main at 7751f96, landed history only, everything reconciles: 126 retro.d paths,
233 versions, changelog.d 177, combined 303. Both of row 14's denominators become the same
126. The conclusions all survive — still exactly two two-heading fragments, still the same two
files, still 10 of 126 unstamped at 7.9%, still 0 of 52 unprefixed stems beginning with any
digit. Only the totals were wrong, and only because they had no anchor.
The rule this leaves: a measurement is reproducible when it states an instrument and a point —
a ref and a sha, not --all and not HEAD. Prefer the landed history as the population, because it
is the only one that does not change when someone else commits. And when a single row carries two
numbers about the same set, check they came from the same run before checking they are right: I
verified the instrument and never verified that the two halves were counting the same things.
The same defect, three times in one day, each time caught by someone re-running rather than re-reading.
The one surface a later pass cannot fix is the one nobody reviews (20260902 07:34)¶
MEDIUM — three pushed commit messages on this branch carry four false sentences between them, and a commit message is the only artefact here that cannot be corrected in place. Every review pass on this branch has read the diff. Pass 9 read the messages, and found that the surface holding this increment's reasoning — the surface a future reader reaches for when asking why a line is the way it is — had never been checked by anything.
| commit | the sentence | measured |
|---|---|---|
0e424c7 |
markdown-links: 113 files, 375 links |
the gate on that exact tree says 114 files. The link count is right |
2914b09 |
"All five test names they name resolve" | the three docs/VERIFICATION.md cells that commit added name 11 distinct tests |
2914b09 |
"startswith("## ") shipped in eight of them and the pass-3 lookaround in three" |
over the population it names — every commit from 50dd420 to 6bc8078 — 5 and 2 |
f1de4c1 |
the mutant "is killed by the negative assertion rather than the positive one" | it dies at the positive one, tests/test_fragments.py:1005, and pytest never reaches the negative |
And the pass that wrote this fragment put two more onto the same unfixable surface, within the hour. Both were caught by re-reading my own diff before landing, which is the only reason they are here as a record and not as a fifth and sixth row somebody finds next week:
| commit | the sentence | measured |
|---|---|---|
f48e1cf |
the unclosed-<div> fixture renders "one <h2> where the source has four" |
the source has three ## headings. The four is anchors_of's count, which includes the # heading — two instruments, one number, and I carried the number from the wrong one |
bc68475 |
"four of them carry a false sentence", of commit messages | four sentences across three messages; 2914b09 carries two. The table underneath it has always been right, and the sentence summarising the table was not |
Neither is a hard number to check. Both were written in a commit whose subject is other people's false counts, and both survived the writing of that commit. A pass that is looking outward does not look at itself, and the cheapest defence found so far is a mechanical one: before landing, re-read your own diff for every number in it and re-derive each, treating your own prose exactly as you would treat a claim raised by a lens.
The fourth is the one with a lesson beyond arithmetic. The false sentence was in the test's own docstring first, and the commit message copied it — "The negative assertion is the load-bearing one", written from reading the two assertions rather than from applying the mutant to them. Both would fail against a stripped message; only the first one reached reports. So the claim was not a slip made in a hurry at commit time: it was a plausible reading of the code that nobody executed, duplicated into a surface where it could no longer be edited. The docstring is corrected in this increment; the commit message is not correctable.
The third is the sharpest, because it was corrected once already and not by that route. 04497a9
replaced the selector — "every commit from 50dd420 to 6bc8078" was a range nobody had examined —
and re-measured over full history, getting 4 and 1. It never re-derived 8 and 3 over the original
population, so it left a false pair standing while reporting that it had fixed the sentence
containing them. Replacing a claim's population is not checking its numbers. Three populations
here, three answers — 8/3 written, 5/2 true for that population, 4/1 true for the one that replaced
it — and only the first is wrong.
LOW — and one of the thirteen findings did not survive being checked, which is the reason the rule
exists. A reviewer reported that 6bc8078 had replaced a correct census — pass 5's "28 of the
tree's 30 battery sections carry the prefix" — with a false 27/19. Measured at 5329d24^, the tree
pass 5 was describing: 30 sections, 27 carrying (19 unreleased, YYYYMMDD · and 8 X.Y.Z ·).
6bc8078 was right. Pass 5's 28-of-30 is the pair that matches no single selector.
And the reason it matches none is a section every census so far has silently dropped. Count a
section as a comment line sandwiched between two # ───── rules and there are 30. Accept ASCII
hyphens as rule characters too and there are 32 — because
tools/batteries/src-pinakes-pairing.toml:369 draws its rules with - and writes
# unreleased, 20260831 - S16's residue…, a hyphen where the convention has ·. It is
non-conforming twice over, it predates this branch, and it is still non-conforming at this
branch's tip. Under the loose selector the pre-fix tree reads 28 of 32. So pass 5's 28 is right
about one population and its 30 about the other, and the two numbers in one sentence come from
two different questions.
This is one class with the fragment landed beside it, not two. retro.d/ also carries
A method is not a measurement point, and --all is not a
corpus,
written the same night about the same defect — a number stated without the selector that produced
it. Read them together: that one names the rule, this one is the rule failing in the hands of the
reviewer applying it.
This started as a bare path in prose, because when it was written the branch was twelve commits
behind the file it cites and a link would have been red on its own gate. Green at the moment of
landing beat the better reference. It is a link now that both are on main, and the reason is not
tidiness: tools/markdown_link_gate.py reads retro.d/, so a path in prose is unchecked text
and a link is a gated reference — this one goes red if either fragment is ever renamed, and the
prose version would have gone quietly stale. Ruled by the planner.
The lesson is not "measure more". All three parties — the writer, the corrector, and the reviewer who called the correction wrong — ran a count. Each ran a different count and none said which. A census of a corpus with two spellings of its own delimiter has two answers, and the one you get is chosen by the regex you happened to write. Naming the selector is what makes two measurements comparable; it is also what makes a disagreement resolvable instead of a standoff.
What to do about a false sentence that is already pushed. It cannot be amended — the branch is
shared and the sha is cited in this fragment and in docs/VERIFICATION.md rows. So the correction
lives here, in the retrospective, which is the only append-only record of the same events. That is
weaker than a fix and it is what is available. The stronger move is upstream: a commit message
asserting a count is asserting something checkable, and nothing on this branch checked one until the
ninth pass. Reading the message alongside the diff costs one command and would have caught all
four before they were pushed.
The census I called correct was the one mixing selectors (20260902 07:41)¶
Reviewing a branch's commit messages, I raised that 6bc8078 had replaced a correct census with a
false one: an earlier pass had written "28 of the tree's 30 battery sections carry the prefix", and
the later commit said 27, of which 19 and 8. I filed it as a regression — a right number made wrong.
The coder re-measured and refuted it, and I re-derived it independently rather than accept the
refutation on report. At 5329d24^, over 11 battery files: counting sections delimited by box-drawing
# ──── rules gives 30 total, 27 carrying (19 + 8), 3 not. Also accepting ASCII # ---- rules
gives 32 total, 28 carrying, 4 not.
So 6bc8078 is exactly right under the strict selector. The sentence I called correct — 28 of 30 —
is the one that matches no selector at all: its 28 comes from the loose count and its 30 from the
strict one. I had promoted an incoherent pair over a coherent one because the incoherent pair was
older, and "a later commit changed a number" is a shape that reads as regression before it is checked.
Neither census stated its selector, and that is the whole defect — the same one I had landed a
retro fragment about ninety minutes earlier: A method is not a measurement point, and --all is
not a corpus.
I wrote the rule and then failed to apply it to the very next thing I looked at, in a review whose
purpose was to catch exactly this.
Why the selector is load-bearing here rather than pedantic: the coder found
tools/batteries/src-pinakes-pairing.toml:369 uses ASCII rules, and across tools/batteries/ there
are 56 box-drawing rules against 6 ASCII. A ─-delimited census is therefore structurally blind
to that file. The two counts are not rounding of one another; they see different trees.
The rule this leaves: when two measurements of one set disagree, do not assume the older is the baseline. Ask which selector each used first — the disagreement is usually the population, not the arithmetic, and the number that survives is the one whose selector was stated.
I armed a CI watcher on a sha I had composed, not read (20260902 08:11)¶
Three consecutive tips had their CI verdicts lost to supersession, so I armed a bounded watcher on
main's tip and told the user I would land nothing until it reported. It polled and reported
nothing. I read that silence as correct and said so in as many words: "My bounded poll has produced
no output yet, which is correct — it only writes when every workflow on that sha reports."
It was not correct. The watcher was polling
6a9245aca7bb5aeb0d0b30ba1c4ab4a5bd9b5cbb. The tip is 6a9245aff04805cff66ed3d1c4f17d72b6fab4dc.
The two agree on the seven characters I had in front of me and diverge on the thirty-three I did not.
I expanded a short sha into a full one by writing plausible hex. gh run list --commit <that>
returns [], the loop's [ -n "$row" ] guard never passes, and the watcher runs to exhaustion
printing an empty verdict block. It could not have reported a red build. It could not have reported
anything.
The verdict arrived from the coder's watcher instead, and I verified it myself: run 33606706108, CI completed/success, 15 of 15 jobs green, headSha matching the real tip. So the outcome was fine and the instrument was worthless, which is the worst pairing — nothing about the result told me the tool was broken. I found it only when stopping the task printed the command back to me.
Two failures, and the second is the bad one.
The first is composing an identifier instead of reading it — git rev-parse was one call away, and the
repo already carries this rule for timestamps (read the clock, never compose it). It generalises: a
sha, a run id, a version, a line number. If it identifies something, read it from the thing.
The second is that I had a null result and treated it as a measurement. Zero rows from gh and zero
output from the watcher are the same shape as not finished yet, and I picked the reading that
matched what I expected. A selector that has never been shown to fire cannot report absence — this
is written down, and I had cited it in this very session while reviewing someone else's work.
What would have caught it, cheaply: before arming any watcher, run its query once in the foreground and confirm it returns a non-empty row. A watcher whose first poll matches nothing is not waiting; it is misaddressed. That check costs one command and is the difference between a hold that means something and a hold that is decoration.
This is the third instance in one night of the same family: a status list read without asking what it was capable of containing (a false RED), a workflow absent rather than cancelled because its path filter never matched, and now a query addressed at nothing. In all three the empty or partial answer was read as information about the instrument's world rather than about the instrument.
A postscript, because it happened while this file was being written. Investigating the above, I
found two landed fragments linking a sibling by filename — forbidden by retro.d/README.md because
the path stops resolving once everything is spliced into one document. I proved it (make docs exit
2, two strict-mode warnings naming that link), fixed both, and then ran git checkout -- retro.d/ to
undo the splice. That restored the two fragments to HEAD, discarding the fix I had just made,
and deleted this file, which was untracked and had been consumed by --apply. The repo warns about
exactly this — commit before mutating; git checkout restores to the last commit, not to the
pre-mutation state. I had also been told the count: ls retro.d/*.md | wc -l printed 24 where
25 was correct, and I read the number without checking it against what should have been there. A
count you do not predict before you read it cannot surprise you, which is the same defect as the
watcher — an instrument consulted with no expectation attached reports nothing either way.
And a fourth instance, inside the fix for the third. Rewriting those two links needed the
sibling's anchor, so I computed it "with the renderer that builds the site" — and wrote exactly that
sentence into b4fa8e8's commit message, where it stands, false. What I actually built was a
markdown.Markdown from mkdocs.yml's markdown_extensions list. That is not the site's renderer:
mkdocs.yml installs GitHub's slug algorithm through mkdocs_hooks.py, and says so in a comment
eight lines above the toc block — "neither Python-Markdown's default nor pymdownx's matches it".
My two greps windowed on markdown_extensions and on - toc, and between them excluded the one
comment written for somebody doing what I was doing. Python-Markdown's default collapses runs of
hyphens; GitHub's does not, because it discards the backticks and keeps the spaces around them. The
anchor I wrote was …-and-all-… where the site emits …-and---all-…. The coder caught it, having
made the identical mistake an hour earlier and reported the identical wrong string to me as verified.
Two things kept that one invisible, and both are worth naming. A code span is not a link, so
make docs returns 0 whatever the string says — the fix for a link that fails loudly was checked by
a build that cannot see the replacement, which is a seam I introduced in the act of closing one.
And neither of us lacked an instrument: we each had one, ran it correctly, and it was one config
short of the thing it claimed to reproduce. A reproduction is a claim about coverage, not about
effort. Corrected against tools/markdown_link_gate.py's github_slugify() and against the built
site/RETROSPECTIVES/index.html, which agree — with a control that the non-collapsing is systematic
rather than a quirk of this heading: three ids on that page contain ---.
A fifth instance, three hours later, and this one is the mirror of the first. Checking whether
two of the morning's tips had passed CI, I ran gh run list --commit 5654cc4 and
gh run list --commit 293d434. Both returned []. For about a minute I believed two landings had
never triggered CI at all. gh run list --commit requires the full 40-character sha; an
abbreviated one — valid to every git command in the repository, and the form the tool itself prints
in gh run list output — matches nothing, exits 0, and says nothing. The control settles it:
gh run list --commit $(git rev-parse 5654cc4) returns two runs. So the first failure in this file
was an identifier I composed and this is an identifier I read correctly, in a form the tool
rejects silently. The rule that survives both is not "read the sha" but "prove the query fires."
git rev-parse before any --commit, and the first poll is still the assertion.
A sixth, and it is a defect in the alerting rule I wrote, not in a command. My standing CI-watcher
guidance alerts on failure, timed_out, startup_failure and action_required, and deliberately
excludes cancelled as "normally supersession." Under that rule this morning was silent, and this
is what it was silent about:
5654cc4 07:55:49 -> 07:58:23 push/cancelled the retro heading + stamp gate
4e13d6f 07:57:47 -> 08:03:34 push/cancelled
6a9245a 08:03:31 -> 08:08:27 push/success
.github/workflows/ci.yml:8-10 sets concurrency: group: ci-${{ github.ref }} with
cancel-in-progress: true, so every push to main kills the run before it. Two consecutive tips —
one of them a new gate's own landing — got no verdict, 2m34s and 5m47s in. Coverage did hold:
6a9245a is a descendant of both, ran 15 of 15 green, and 293d434 later did the same. But it
held by ancestry, and ancestry is not what the watcher was checking. Nobody asked. I had written
"three consecutive green tips" into my own resume file naming the three that completed, without
noticing that between them sat two that had not.
cancelled is not a state, it is two states wearing one word: a superseded run whose commit a
later completed run contains, and a lost verdict whose commit nothing has yet certified. Telling them
apart costs one ancestry check — is there a completed, successful run whose sha has this commit as an
ancestor? — and without it the exclusion converts an unverified tip into silence. That is the same
defect as every other one in this file: an instrument that cannot distinguish "fine" from "never
looked", consulted with no expectation attached. It is worse here than in the others, because
this instrument's whole purpose is to be trusted while nobody is watching, and because I am the one
who wrote the exclusion into the rule.
A seventh, and it is the sharpest one here, because I built it while fixing the sixth. Twenty
minutes after writing the paragraph above, I armed a v2 watcher whose whole purpose was to stop
reporting cancelled as if it were nothing. It ran, polled thirteen times, and printed this where
the verdict belonged:
SyntaxError: unexpected character after line continuation character
python3 -c 'import json,sys; [print(f" {r[\"workflowName\"]} …")]' — inside a single-quoted
-c, the shell passes the backslashes through, and {r[\"workflowName\"]} is not valid inside an
f-string. Every other query in the same script used --jq and worked; this one line reached for
Python because I wanted two fields on one row. The rest of the script survived: the job count printed
15, the non-success selector printed nothing, and the run really was green — confirmed
independently, gh run list giving completed/success and the job list's non-success set empty.
So the shape is exact: the instrument written to stop an instrument reporting falsely, reported
falsely. And it failed in the one direction that is hardest to notice — it suppressed the line
naming the workflows and their conclusions, which is the line the cancelled finding was about, while
still printing a reassuring 15 underneath. Had a run actually been cancelled, the discharge check
would have run and its output would have sat directly below a stack trace I might have skimmed past
on the way to the green number.
The rule this file has been circling, stated plainly at the seventh attempt: a checking tool needs its own control, and the control is not "does it run" — all seven of these ran. It is make it print the thing you already know. One green sha through the watcher before arming it; one known-red mutant before believing a mutation run; one heading you have already resolved before trusting a slugifier. Each of the seven would have died in under a minute against a case whose answer I held. I have written that sentence three times tonight in three different registers and then not done it, which suggests the failure is not knowing the rule.
The config does not name the renderer (20260902 08:30)¶
A peer replaced a forbidden link with an anchor and asked me to check the string. I built a renderer
from mkdocs.yml, ran the heading through it, and reported the anchor correct. It was wrong by two
hyphens, and I had certified it.
The site's slugifier is not in mkdocs.yml. mkdocs_hooks.py's on_config installs
_github_slugify onto the toc extension at load time, so markdown_extensions — the list I read —
describes every part of the renderer except the one part the question was about. Measured against the
heading in question:
| instrument | result |
|---|---|
mkdocs_hooks._github_slugify — what the site runs |
…-and---all-is-not-a-corpus-… |
Python-Markdown's default toc slugify — what I ran |
…-and-all-is-not-a-corpus-… |
pymdownx.slugs.slugify |
A-method-… — keeps the run, keeps the capital |
Two of the three reconstructions are wrong, in two different ways. Only the hook is right, and the
hook is exactly what is not declared. The repository already knew this: mkdocs.yml lines 86–88
at 6a9245a say "The slugifier is installed by mkdocs_hooks.py, not named here", three lines above - toc:.
The warning was inside the region I read, and my reader could not represent it. I loaded the
block with yaml.safe_load, and a YAML parser discards comments by construction — the one sentence
written for this mistake was invisible to the instrument making it, not somewhere else in the file.
A peer reconstructing the same string independently windowed two greps from markdown_extensions to
- toc and excluded the comment between them. Different reader, same blind spot, same wrong
string. For about an hour we treated that agreement as corroboration. Two instruments built from
the same incomplete source do not check each other; they repeat each other.
One correction I owe my own notes: I first recorded the cause as a SafeLoader subclass I had
written to stub !!python/name: tags past a ConstructorError. Checking it to write this, it is
not — those tags carry the emoji index, and the slugifier was never in the YAML for a stub to drop.
The stub was a near miss, not the mechanism: it is the moment I started patching around the config's
irregularities and read them as noise.
The rule is cheap and I had it available the whole time: when you need the site's renderer, build
the site. make docs, then read the id= attribute out of the built HTML. That is what settled it
in the end — the correct anchor read off site/RETROSPECTIVES/index.html, with a control confirming
the two-hyphen string appears nowhere among the 904 ids on the 30 pages built at 8d71489. A null result carries
no information until the selector is shown able to fire, and a positive one carries none until the
instrument is shown to be the one that ships.
This is the same defect as A method is not a measurement point, and --all is not a
corpus, one layer down: not
a number measured over the wrong population, but a string measured with the wrong program. The
sentence that names it is the anchor to that fragment, which is the string I got wrong.
Three more in the same hour, all caught before they cost anything, all the same shape. A
--check run through grep -v returned an empty FRAGCHECK_EXIT=. A nohup ./check.sh > log 2>&1 &
discarded the gate's status entirely, leaving me about to read "all gates green" off stdout and
call it a gate — a backgrounding form eating the exit status is the pipe rule wearing different
clothes, and it is not a trap peculiar to nohup. And a public-repo secrets vet used a lookahead
ugrep rejects: the pattern errored, the || branch fired, and it printed "clean: no paths,
addresses, secrets or external URLs" — a false all-clear from a check that never ran, on the one
gate where a false clean is worst. Each was found by asking the same question the anchor needed: not
what did it say, but was it able to say anything else.
The restore command is the one that eats the work (20260902 08:53)¶
I proved a change by running the thing it changes: splice both fragment streams, build the site,
read the anchors out of the built HTML. Then I ran git checkout -- . to put the consumed fragments
back, and destroyed my own uncommitted increment — the gate, its nine tests, and four link
restorations. git status came back empty and that was the whole report.
The rule is already written, in the mutation section: "git checkout <file> restores to the last
commit, not to the pre-mutation state, and silently reverts uncommitted fixes." I had read it. What
it did not stop is the reason it did not stop me: the operation felt like a read. Splicing and
building are a measurement, and the command that undoes a measurement does not present itself as a
write. A peer did the identical thing four hours earlier on the same night, lost a fragment, and
rewrote it. Two agents, one command, both having read the rule — that is a defect in the remedy
rather than in either of us. The remedy that would have worked is one word longer: commit before
the measurement, not before the mutation, because a splice is a mutation whatever it feels like.
Recovery cost about ten minutes, and only because I had copied retro.d/ and the gate to scratch
first — for reasons unrelated to this, and not the test file, which I rewrote. Everything was then
re-verified from the restored tree rather than from the earlier run's output, since the earlier run
had measured a tree that no longer existed.
A second instrument failure the same hour, and it is the sharper one. A peer and I each counted
the live ](#…) links in docs/RETROSPECTIVES.md. We both got 2. The population is 3.
Their pattern, \]\(#[a-z0-9][a-z0-9-]*\), misses #measure_sync_cpupy-… because of the
underscore. The pattern I attributed to them — a grep quoted inside another fragment — misses
#design-review-passes-17-pre-implementation because it requires a -YYYYMMDD-HHMM suffix. Two
selectors, two unrelated blind spots, one agreeing wrong answer. Agreement between instruments is
worth nothing until the instruments are shown to be independent, and these looked independent
precisely because neither of us had written the other's regex down. Earlier the same night the same
pair of us computed the same wrong anchor by the same wrong method and read that as corroboration
too; this is the harder version, because here the methods really were different.
And a third, which is the one that generalises. A peer measured a line at 254 characters and
dismissed two neighbouring lines as "2 characters over, not worth a commit". The line was 252
characters and 254 bytes — awk length counts bytes in this locale — and the two neighbours
were 99 and 100, never over at all. The headline survived the error: at 252 or 254 that line was
two and a half times the file's width and had to be fixed either way. That is exactly why the
error was safe to keep. A measurement can be wrong in a direction that does not touch the
conclusion and still poison every judgement made beside it, and those judgements are the ones nobody
re-checks, because the headline held. The number the conclusion rests on gets scrutinised; the
numbers riding along beside it do not.
The find worth keeping came from reviewing the change after it was green. A stream README.md
is excluded from the fragment set — its own links resolve where it sits — and it must also be
excluded from the anchor universe, because its headings never reach the spliced document. Two
exclusions, two functions, and only the first had a test. Without the second a fragment could link
#how-to-write-a-fragment, pass on the branch, and die at the release cut: the exact failure the
new gate exists to remove, produced by the gate. Nothing about it was visible in the diff, and the
suite was green when I went looking.
The fix for a loud crash was a silent deletion, and only a probe said so (20260902 09:27)¶
S1 read as a one-line fix: hash_file lets PermissionError escape the walk, so catch it and skip
the file. The crash is loud, the traceback is ugly, and skipping is obviously kinder.
It is not. pair() decides what to do with an indexed document by whether the walk still reports
its path — absence means gone. A walk that skips an unreadable file therefore emits SoftDelete:
the row retired, its chunks deleted, pnk sync printing 1 removed and exiting about something
else entirely. A chmod 000 would have removed the document from search. The obvious fix turns
a loud crash into silent index loss, which is the shape this repository ranks worst, and it would
have shipped green.
Nothing in the plan said this. It was found by writing eleven lines of throwaway script that built
an IndexSnapshot and a WalkSnapshot by hand and printed what pair() returned — before a line
of the fix existed. The whole finding cost one tool call.
The rule that generalises: when a fix changes what a downstream consumer is told, ask the consumer
before writing the fix. Not after, and not by reading it. Reading pair() would have worked too —
the vanished-path loop is right there — but reading is what produced the one-line plan in the first
place. The probe answered a question I already believed I knew the answer to, which is the only kind
of question worth spending a tool call on.
The fix's own first output was a command that destroys a ULID¶
Running it printed orphaned sidecar (kept; remove with pnk doctor --prune): docs/c.md.pnk.yaml
for a document sitting on disk, readable sidecar and all. _orphans derives orphanhood from the
walked file set, and the unreadable document had just left it. Following that remedy deletes the
sidecar, and with it a permanent id that other KBs may link to.
It was found by running the fix on a real KB with the real [light] backend, not by reading the
diff — the diff is three lines and every one of them is correct. This is the seam rule paying out:
unit tests prove a component honours a parameter, and only running proves what the user is told.
An exit 0 from an instrument that never reached its population¶
pnk doctor was checked against an unreadable document and came back clean — exit 0, no traceback.
The conclusion "doctor is fine" was one keystroke away and would have been wrong.
The row had been marked extraction_backend='claude', and the only paid backend name is
claude-vision. So recorded_is_paid was false, the loop body never ran, and the unguarded
hash_file was never reached. The tell was in the output and easy to skim past: paid extraction
not requested: none. With the real name substituted, doctor died in a PermissionError
traceback.
That is the fourth time in two days a null result nearly became a finding here. The others were a
grep pattern with a lookahead the tool rejected, a sed range using \b that BSD sed does not
support, and a grep 'walk_sources(' over tests/ that returned nothing because the call site
aliases it to real_walk. Same shape every time: the instrument could not reach what it claimed
to have checked, and its silence read as an answer. A control that must fire is the cheapest
insurance in this repository, and it is still not automatic for me.
A gate against unnoticed drift, and the two shapes it nearly could not see (20260902 10:07)¶
Row 15 was recorded as one comment on one line: a battery section header reading # 20260823 · …
where the reserved forms are a version or unreleased, YYYYMMDD. The header itself had already
been fixed. What was left was the gate — refused once, while the question of whether adding a check
counted as new process was open, and unblocked when that question was answered.
Writing it produced two findings, and neither was the one the row described.
The audit could not see the second instance, because of the shape of its own selector. Row 15
concluded this was "the only one matching neither". It was not. src-pinakes-pairing.toml carried
# unreleased, 20260831 - S16's residue: … — a hyphen standing exactly where the · separator
belongs. The audit had searched for headers containing ·, so the one other non-conforming header
in the tree was the single shape its query could not return. The gate found it on its first run.
A selector defined by the correct form cannot find a violation that consists of that form being
absent. The row's other numbers fail the same way, and its own arithmetic said so without any
re-measurement: it reported 30 sections, 19 unreleased, YYYYMMDD ·, 8 X.Y.Z ·, one matching
neither — and 19 + 8 + 1 is 28, two short of its own stated total. Measured at a4077b5, the
commit that wrote it, the tree held 30 sections: 20 unreleased, 8 version, two matching
neither. One invisible header is missing from exactly two tallies, which is exactly the two the row
was short.
And I made the neighbouring error while correcting it. My first correction tabulated 36
sections, 26, 9, 2 against the row's 30, 19, 8, 1 and called the total wrong. It was not: the
tree grew from 30 to 36 between a4077b5 and today, and the row's 30 and its 8 were both right for
the tree it was measuring. What was wrong was 19 and only one. Comparing today's tree against a
claim about a past tree turns growth into error and hides the real defect — so a count correction
has to name the commit it was measured at, not just the selector.
Then the gate itself nearly shipped with the identical defect. Battery sections are fenced by
horizontal rules, and the first RULE regex matched only the box-drawing U+2500. Nine batteries
use it. Two use ASCII hyphens — and one of those two was src-pinakes-pairing.toml. So the gate
read 34 of the tree's 36 headers, reported a clean sweep, and the file it skipped was one of the
two that had drifted. A gate written to catch unnoticed drift, silently skipping the drift, on its
first run, in the increment whose whole subject is a selector that could not match its target.
It was caught by refusing to accept a clean result. non-conforming: [] is a null result, and a
null result carries no information until the instrument has been shown able to fire; counting the
headers found against the headers on disk is what turned [] from a pass into a discrepancy. The
same discipline had already been spent twice this increment — on a search assertion that passed for
a word in no document, and on a git cat-file lookup where zsh ate :t out of "$tag:tools/x.py"
as a history modifier and reported a file absent from a tag that contains it.
What generalises: a checker's own matcher is part of what it checks, and it is the part nobody tests. The assertions were right in all three cases. What was wrong was the set they ran over. So the gate now counts both fence shapes, says in a comment why rather than leaving a magic alternation, and the docstring names what it deliberately does not check — calendar validity, and section ordering — because a gate that quietly grows a second subject is how the next selector ends up not matching the thing it is looking for.
I published a count I never ran, and a peer nearly stopped a correct release (20260902 10:26)¶
I reported the 0.32.0 splice as "12 changelog + 26 retro". The tree held 13 and 27. The
release was correct; the number in my status message was not, and I had not measured it — I read it
off my own earlier prose rather than off git ls-tree.
What it cost. The coder reasoned correctly from my figure and reached a serious conclusion: if one fewer of each was spliced, the missing pair is exactly S1's, and 0.32.0 would ship the fix while describing nothing. It messaged "stop before the tag". The tag was already pushed by the time the message arrived, so the cost was a verification round rather than a release — but the reasoning was sound and only the input was wrong.
Two commands settled it, both of which I should have run before quoting a number:
git ls-tree --name-only v0.32.0 changelog.d/ retro.d/ | grep 20260902_0927 # empty
git show v0.32.0:CHANGELOG.md | grep -c unreadable # 18
The lesson is not "count more carefully". It is that a number inside a status report is a claim
with the same standing as a number inside a document, and it travels further and faster because
peers act on it directly. This repository already rules that a claim resting on a set you selected
must state its selector. A count in a message has a selector too — git ls-tree <ref> <dir> — and
naming it is what makes the claim checkable by the person receiving it.
What the coder did right, and it is the reusable half: it said plainly that it could not verify the branch (it was not on the remote), declined to assert the defect, and handed me the two commands that would settle it either way. A well-formed doubt with its own falsification attached costs sixty seconds. The same doubt asserted as a finding would have cost an argument.
The gate I ran was not the gate CI runs, and the gap is where the defect was (20260902 10:26)¶
./check.sh exited 0 on the release branch. main went red on the same tree.
Not a flake, and not a different commit. My local run skipped 128 tests because this machine has
neither the [pdf] nor the [claude] extra installed, and CI's check job is a three-leg matrix
over [light], [light,pdf] and [light,pdf,claude]. The failure was in
tests/test_pdf_trace.py — inside the skipped set, by construction.
The defect it found was real and had been dormant for a month. A test asserts
reservation.cost_eur == estimate.per_request_eur, and that comparison straddles the ledger's
write-time quantisation: accountant.py:193 multiplies the EUR estimate back by the rate,
ledger.py:139 quantises on write at 1e-6 ROUND_HALF_UP, ledger.py:127 divides back on read.
| rate | per_request_eur × r |
quantised | read back | equal |
|---|---|---|---|---|
1.1596 (seeded) |
0.3535000000000000000000000000 |
no-op | …3083822 |
yes |
1.08 (the original seed) |
0.3535000000000000000000000000 |
no-op | …8148148 |
yes |
1.159 (2026-09-01 ECB) |
0.3534999999999999999999999999 — one short |
snaps up | …5452977 |
no |
So the assertion had never held by construction. It held because two successive rates happened to
make the round trip land exactly on the quantum. Refreshing that constant — a step
docs/RELEASING.md requires at every release — is what falsified it.
And I published a different mechanism before checking this one. I read
cost_eur = cost_usd / usd_per_eur, assumed cost_usd was the summed USD, and wrote up an
arithmetic-ordering story — (a/r)+(b/r) against (a+b)/r — in three documents and a commit
message. It is a coherent account that predicts the observed digits, which is exactly why it
survived my own reading. A peer reproduced the real route and refuted it. The fix I recommended on
the strength of it would have removed the …9999 from this fixture and left the assertion false
for 66% of a 40 000-case sweep; restricted to requests == 1 it does hold, which is the trap,
because the fixture is requests == 1 and so is any sweep someone writes to check it.
The rule I am taking from that: an explanation that fits the numbers is a hypothesis, and the discriminating step is reading the code path that produces them, not the code path that would.
Three things follow, and only the first is about me.
A skip is not a pass, and a green local gate is a claim about the legs that ran. ./check.sh
prints its skip count; I read the exit status and not the count. The honest form of "green locally"
is "green on the one leg my machine can run".
A test whose truth depends on an unmentioned constant is not pinned to what it claims. Nothing in
test_pdf_trace.py names usd_per_eur, so nothing warned that changing it could break the
assertion — and nothing warns now that a future refresh could make it pass again for the same bad
reason. A second rate in the test is what turns a coincidence into a pin.
The order of discovery is worth keeping. The artifact was verified — installed from the index,
wheel opened, subject found inside it — before CI went red. A verified artifact and a green
main are two claims, and it is possible, as here, to have only the first. Neither substitutes
for the other, and a release note that reports one as though it were both is the error this record
exists to prevent.
Both hops were green for the value of a constant neither of them mentions (20260902 10:45)¶
Two assertions in tests/test_pdf_trace.py, one live and one latent, were correct against every
input anyone had ever run them on. Neither was a badly written test. Both depended on the value of
a constant that appears nowhere in their text, and this repository refreshes exactly that constant
on a schedule.
| hop | what it compared | why it was green | what ended it |
|---|---|---|---|
| 2, reservation | cost_eur against estimate.per_request_eur |
at usd_per_eur = 1.1596 the multiply back landed exactly on six decimals, so quantisation was a no-op |
the 20260901 refresh to 1.159 |
| 4, reconciliation | a bare Decimal.quantize() against the ledger's ROUND_HALF_UP |
today's prices make per-token USD exactly six decimals, so there is never a tie to resolve | nothing yet — it is latent |
The trigger is scheduled, not hypothetical. docs/RELEASING.md § Before you start step 3
refreshes prices.toml at every release. That step is the input to both assertions and is named in
neither. A price-granularity check — something that asks whether a refreshed rate or per-token price
still leaves the arithmetic on the quantum — is arguable on this evidence. It is deliberately not
built here: that is new process, and new process is not an implementer's to invent.
Measured, because the plausible story was wrong twice. A rearrangement that divides once instead
of twice looks like a principled fix; over 40 000 randomised (rate, cost, requests) cases it fails
54% of the time — and 0.00% at requests == 1, which is the fixture's own shape. It would
have passed this test, and any sweep shaped like the fixture, while leaving the assertion
structurally false. My own first proposal failed 1.81% of the same sweep, because it inherited
the very rounding-mode defect I then found in hop 4.
What the fix stops covering, said plainly. Both sides of hop 2 now go through the production
quantise(), so it cannot fail on arithmetic: it pins the plumbing — that the reservation carries
this estimate and not another number — and nothing about the arithmetic that produced it. Four
mutants confirm the shape rather than assert it: an accountant that adds one quantum and a
claude.py that reserves a different estimate field both die; PROMPT_TOKENS 700 → 701 and the
ledger's rounding mode both survive. The survivors are not a gap to be closed but a seam to be
named: the test recomputes the estimate from estimate_document, so any mutation inside that
function moves both sides of the assertion equally and is structurally invisible to it.
And the word "pinned" does not apply to hop 4. Hop 4 is green today with or without the change, so no ordinary run can demonstrate it; it is a latent divergence closed before it fired, and showing it requires changing a price. Saying "pinned" would be claiming a failing test that does not exist.
A mutant killed for the wrong reason is a survivor wearing a green light (20260902 11:59)¶
The S4 battery came back 6 mutants, 6 killed, 0 survived on its first run. One of the six was not measuring what its row claimed, and the count could not say so.
The row is named "a backslash is left raw, so a Windows-style path parses as a name nobody
typed". It removes the \\ entry from _TOML_ESCAPES, and its whole point is the quiet
failure — a manifest that parses, that pnk doctor calls healthy, holding a name the user never
typed. It killed through the eleven-value corpus, whose backslash value is C:\notes\kb.
That value cannot demonstrate the property. It contains \k, which TOML defines as nothing at
all, so tomllib rejects the file:
tomllib.TOMLDecodeError: Unescaped '\' in a string (at line 2, column 23)
A parse failure — loud, and caught by every test in the file. The round-trip half the row is named for was asserted by nothing. Measured across three shapes:
| value | without the backslash escape |
|---|---|
C:\notes\kb |
rejected — \k is not an escape |
C:\notes |
parses, reads back C: + newline + otes |
C:\build |
parses, reads back C: + backspace + uild |
The fix was one test over C:\notes and repointing the row's kills at it. The mutant now dies
on AssertionError: assert 'C:\notes' == 'C:\\notes' — an equality, which is what the row claims.
Three things worth keeping, none of them about escaping.
A clean aggregate is a fact about the harness before it is a fact about the subject. 6 of 6 killed has the same shape as a 30-agent verification panel that overturns nothing, and as a mutation run with no kills: consistent with the instrument working, equally consistent with it not discriminating, and the run cannot tell you which. Both happened in this repository on the same day, hours apart. The count is the last thing to read, not the first.
Read the kill reason, never the kill count. This was found only by reading the failure text of
a mutant that was already dying. Nobody does that when the number comes back clean, which is
precisely why nothing else could have found it: tools/mutate.py reports KILLED correctly, the
anchor resolved exactly once, tests/test_batteries.py was green, and the row's prose was
self-consistent. Every gate this repository owns was satisfied.
When prose names a value to demonstrate a property, run the value. The wrong example was written
into a battery header whose own subject is parsing is the weaker claim — and it then survived
being relayed to a second agent and dictated back as finished prose for a third file, still
unmeasured. It was checked when it was executed, not when it was read, reviewed, or agreed. The
general form is now scoped onto the queued row for the post-render validator: a remedy, a docstring,
a --help string or a dictated paragraph that names a value to make a point is run, not reasoned
about.
An example I reasoned about instead of running (20260902 12:00)¶
I dictated a sentence into tools/batteries/README.md claiming that without the backslash escape,
C:\notes\kb renders valid TOML that reads back as a name nobody typed. The coder's mutation
run falsified it before it landed. tomllib rejects that value — \k is not a TOML escape at
all — so the mutant it described dies on a parse error, which is the opposite of the point the
sentence was making.
The phenomenon is real; the example did not have the property. C:\notes parses and reads back as
C: + newline + otes; C:\build parses and reads back as C: + backspace + uild. Three
seconds of tomllib.loads separates them, and I ran it only after being contradicted.
Where it was going to land is the part worth keeping. That README's own subject is that parsing is the weaker claim, and the paragraph exists to teach the difference between a manifest that parses and one that round-trips. A worked example that fails on the parse side would have taught the opposite of the file's thesis, in the file's own voice, under a heading that made it authoritative.
The rule this repository already had did not cover it. A claim resting on a set you selected must state the selector is about populations. The command ran, the number was typed is about a figure copied out of prose instead of out of output. Neither reaches a value chosen to demonstrate a property, which is not a measurement at all — it is an argument, and it feels like reasoning rather than reporting, so nothing prompts you to execute it. The generalisation:
When a message, a header, a docstring, a remedy or a
--helpline names a value in order to demonstrate a property, the value is run, not reasoned about. An example is a claim with an executable form, and the executable form takes seconds.
The catch came from a mutation run, and only because someone read the failure text. The battery
reported six mutants killed and none survived. One of the six was dying on a TOMLDecodeError
while its row claimed a round-trip property that nothing in the suite asserted — so the row was
green on a weaker property than its own name disclaims. A clean kill count concealed it; the kill
reason did not. A mutant killed for the wrong reason is a survivor wearing a green light, and
the aggregate that hid it has the same shape as a refuter panel that overturns nothing and a
Blocked on cell nobody re-reads: a number that describes the instrument rather than the subject.
What the exchange did right. The coder held the dictated text rather than pasting it, said which claim it was refusing and why, and brought the run output and a three-row table of measured values. Refusing planner-dictated prose is the harder direction of the ownership split — the whole mechanism is content mine, keystrokes yours — and it is exactly the direction that has to work, because a dictated sentence arrives with more authority than it has earned.
S4's fix reproduced S4, on the one value class the framing excluded (20260902 21:18)¶
S4 was: a KB name containing a quote, a backslash or a control character wrote a pinakes.toml
no parser could read, while pnk init exited 0 and printed created. pnk init refuses a
directory that is already a KB, so the remedy surface was empty and recovery meant hand-editing
TOML. The fix escapes every interpolated value for the TOML basic string it lands in.
Measured on the branch that carries that fix, through the real CLI:
pnk init /tmp/kb --name $'kb-\xff-name'
→ uncaught UnicodeEncodeError from Path.write_text (init.py:370)
→ /tmp/kb/pinakes.toml exists, 0 bytes
→ retry: "error: /tmp/kb is already a KB."
An unreadable manifest, init refusing the directory, an empty remedy surface. The same three
sentences, on the branch whose whole purpose was to delete them.
The framing excluded the class, so no amount of care inside it would have found it¶
\xff on a command line is not valid UTF-8, and POSIX decodes it with surrogateescape (PEP 383)
into U+DCFF — an unpaired surrogate. TOML's basic string admits %x80-D7FF and %xE000-10FFFF
raw and skips the gap, and \uXXXX must name a Unicode scalar value. So the character has no
TOML form raw and none escaped. It is not a hard value to escape; it is a value for which
escaping is not the answer.
Every artefact of the increment said escape. The fix is an escape function. The tests are named for round-tripping. The docstring's "region this cannot reach" paragraph names TOML positions — literal strings, bare keys, numbers — and no values at all. The battery's six rows each removed one escape and watched a test die. Six mutants, six kills, and the whole apparatus was blind in the same direction, because the question it was built to ask was "is this escaped correctly", and the answer for this class is "there is nothing to escape it to".
The check that would have caught it is one question asked before the fix, not after: what is the set of values this promise is over, and does every member of it have an image? Enumerating the domain is a different act from testing the function. Eleven values were chosen for the corpus, all of them representable, by people looking for hard-to-escape characters.
The refuter was right about the mechanism and wrong about the disk¶
The reviewer that found this rated it medium. Its stated reason: "the crash happens before that
file is written, so this does not brick the target directory — a retry with a valid name
succeeds." Both halves are false. Path.write_text opens and truncates, then encodes, so the
zero-byte file is already there when the encoder raises; and the retry is refused.
The refuter reproduced the traceback and reasoned from it to a state it never looked at — ls and
wc -c were one command away. This repository's recurring failure has a shape, and this is it:
a valid inference over a population nobody enumerated. The judge ran the command and overturned
the severity upward, which is the only reason it was not landed as a medium.
Making a value legal relocates the question; it does not answer it¶
A second finding from the same review, measured and confirmed: pnk budget prints kb.name raw,
so a name carrying an ANSI escape reaches the terminal as a live escape sequence, and a name
carrying a newline breaks render()'s one-entry-one-line contract with no error anywhere.
Before the fix, such a name bricked the KB, so nothing downstream ever saw it. Escaping for TOML discharges the obligation TOML has, and discharges nothing that a terminal, a filename, a log line or an HTML page has. The moment a class of input stops being rejected, every consumer of that value inherits a question it was never asked — and none of them changed, so none was reviewed. The increment's own tests cannot see this by construction: they assert the value comes back out unchanged, which is exactly the property that delivers the bytes downstream.
The check is cheap and was not run: who reads this value now, and what does each of them assume
about it? Here that is one grep and twelve call sites — eleven are JSON payloads or in-memory
comparison, and budget/summary.py:193 is the single plain-text consumer.
One correction worth keeping, because the narrower claim is the defensible one: the review called
this previously unreachable. It was not — a hand-written manifest carrying \u001b always
parsed and always printed raw, on main too. The fix changed the route, from hand-edit your
TOML to pass a flag. A widening, not a creation.
Code no test can pin, found by a count that came out wrong¶
The judge proposed excluding bool from the new allow-list: isinstance(True, int) is True,
and str(True) is True where TOML's literal is true. It was built, and a test was written for
it, and both were removed — because the escaper escapes content and never adds quotes, so a
bool renders True from either branch: bare at dim = {{ embedding_dim }}, and inside the
template's own quotes at name = "{{ name }}". The exclusion had no observable effect at any
interpolation in the file.
What exposed it was not review. The fragment claims a red/green split, so the split gets measured by removing the hook and running the file. It came out 5 green where the four controls were known and named. One line of arithmetic, and the fifth green test was the new one — passing without the fix it was written to pin, with a battery row that would have survived. The arithmetic was the instrument; nobody read the test and saw it.
The review harness said "found nothing" when nobody had looked¶
The third adversarial pass over this increment returned, in its own words, "PASS FOUND NOTHING
that survived refutation". All four of its lenses had stalled and errored; agents_done was
0. The verdict string was mine — the script tested survived.length === 0 and never asked
whether anyone had looked, so a vacuous run and a clean run left by the same exit.
Pass 1 had already shown the softer version: 8 raised, 4 confirmed while five of its fourteen agents were dying on a session limit, one whole lens never running, and two findings never refuted — one of which was the only real code defect in that pass. Truncation is not random with respect to value: a lens that reads text finishes first, and a lens that must build a fixture and run a command reports last, so the expensive lens is first to be cut and it is the one that finds behaviour.
The fix is structural, not a habit: the harness records which lenses actually returned and reports VACUOUS / PARTIAL / CLEAN rather than a count. A count cannot carry the difference, and asking a human to read the error list first is asking them to distrust the summary they were given.
Which is the same lesson as this increment's earlier one arriving from the opposite direction: there, the aggregate was clean and the kill reason was wrong; here, the reason would have looked fine and the count was wrong. Keep a number in the fragment that has to be re-derived. It is the cheapest adversary in the repository.
A commit taken inside a mutation window shipped the mutant (20260903 08:30)¶
7193983 on the S4 branch has the subject "Retro: the review harness reported a clean pass while
nobody had looked". It changed a retro fragment — and src/pinakes/template.py, by one line:
- elif character != "\t" and (character < " " or character == "\x7f"):
+ elif False:
That is battery row a control character is left raw, live on disk, committed and pushed. Every control character in a KB name would have gone raw into a TOML basic string, which is the S4 defect this branch exists to delete, on the branch that deletes it.
The window is seven seconds wide and it is nobody's fault in particular¶
7193983 committed 20260903 02:46:58
template.py mtime 20260903 02:47:05 <- tools/mutate.py restoring, 7s later
The commit ran inside a mutation run. tools/mutate.py writes a mutant, runs the named test,
restores the file; git add called anywhere in that interval stages the mutant, and the restore
afterwards leaves the tree clean, so there is nothing left over to notice.
CLAUDE.md already says commit before mutating — because git checkout <file> restores to the
last commit and silently reverts uncommitted fixes. That rule was followed here: the target was
committed before the battery ran. The failure is its unwritten half, in the other direction:
While a battery is running, that worktree has no committable state. Not for the files the
battery names, and in practice not at all, because git add -A does not know which those are.
What did not catch it, and what would have¶
./check.sh |
started, killed at ten minutes by machine load (four review agents), deferred to landing time with a note saying so. The note was honest and the gate still did not run |
| the battery itself | ran and reported 9/9 killed — correctly. A battery mutates a committed target and restores it. It has no opinion about what else was committed while it worked |
--check-anchors |
green. The anchor was present; it was the arm that was gone |
| reading the commit | src/pinakes/template.py \| 2 +- sat in the stat output under a subject that promised a fragment. Nothing but a human reads that line |
The cheap durable guard is the last row: a commit's stat is read before the commit is made, and
a src/ path under a Retro: or Docs: subject is a stop. The expensive one is the first: a gate
deferred because the machine is loaded is a gate that did not run, and the branch was pushed in
between.
The generalisation, because this is the second instance of it this increment¶
The earlier fragment
in this branch is about a mutant that died for a reason nobody read. This one is about a mutant that
was never in the report at all. Both are the same shape: the mutation harness's output was
believed about a state nobody looked at. 9/9 killed was true of the file mutate.py held. It said
nothing about the file git held, and the two were different for seven seconds.
Three mutants killed for the wrong reason in one increment, and the pattern has three shapes (20260903 09:03)¶
A mutation battery reports KILLED when the named test fails. It does not report which
assertion failed, in its summary table — and the table is what gets read, quoted into a commit
message and carried into a fragment. Three rows in the S4 battery died on an assertion other than
the one their name claimed. Each is a different mechanism, and each is generalisable.
1. The mutant was a safe version of the edit it names¶
Row: the refusal echoes the value it is refusing, straight to the terminal. It substituted
{text!r} — and repr escapes a surrogate and an ESC by construction, so the mutant produced
a terminal-safe echo. Measured:
| assertion | {text!r} |
U+…{ord} in {text} |
|---|---|---|
"U+DCFF" in message |
False ← the only kill | True |
"\udcff" not in message |
True | False |
"\x1b" not in message |
True | False |
The kill came from the words U+DCFF going missing. The two assertions the test's docstring calls
load-bearing — never echoes the value — were never measured at all, and would have survived a
mutant that actually echoed.
The check: a mutant is written against a property, so name the assertion it must fail before writing it. "Does the test go red" is the weaker question, and it is the one the tool answers.
2. The mutant raised a different exception, so every assertion after raises was unreachable¶
Row: the surrogate arm is removed. Under it, the refusal never fires, Path.write_text raises
UnicodeEncodeError, and pytest.raises(TemplateError) does not catch it. The test dies at the
raises, so the line below it — assert not root.exists(), which the docstring calls the
load-bearing half — never runs. Measured against the real init(): root.exists() True,
pinakes.toml 0 bytes.
This is structural, not a slip: assertions written after a pytest.raises block are pinned only
by mutants that keep the raise. A mutant that removes the raising code cannot reach them, however
green the report looks.
No edit to template.py could fix it, because render_manifest runs before root.mkdir — so the
row moved to src-pinakes-init.toml and mutates the ordering instead. It now kills on
assert not True.
3. A more general arm below shadowed the specific one¶
_TOML_ESCAPES maps \b, \f, \n, \r to their single-letter escapes. Every one of them also
satisfies the \uXXXX fallback two branches down. Drop any entry and the manifest still parses and
still round-trips the exact value — every round-trip test in the file stays green, and no
mutant could distinguish four lines of live code.
What separates them is only the bytes a human opens: name = "a\nb" against name = "a
b".
That is now asserted, in the same shape as the tab control.
The strongest demonstration was accidental. The planner, having just read the description
above, tried to build the unescaped case by dropping \n from the map — and the output still came
out escaped, because the fallback caught it silently. It had to disable both arms to see the
defect. A shadowed line resists being shown to be shadowed, by someone who already knows it is.
The check: for any specific case, ask whether a more general arm below it produces an acceptable answer. If it does, the specific arm is unobservable and the test suite is silent about whether it exists.
What the tool does and does not print¶
tools/mutate.py does print the failing assertion, on the per-mutant line. All three of these
were found by reading that line. What it does not do is put it in the summary table, which reads
| <row name> | KILLED | <test id> |
— the row's own claim about itself, and no evidence for it. That table is what gets read and quoted. Moving the reason into it is rowed, not built here.
(This section is itself the fourth claim in this increment asserted before being run: an earlier draft said the tool prints no kill reason at all. It was written inside the paragraph about evidence going unread.)
Why the corpus could not have found any of the three¶
Same shape as this increment's surrogate finding: every artefact asked does the test go red, and all three defects live in why it went red. A count cannot carry that, and neither can a green suite. What found them was a review pass that re-derived each kill's reason — one question per row, asked of evidence the tool had already printed.
The summary sentence disagreed with the table one screen above it (20260903 10:23)¶
MEDIUM — a plan's prose register and its own table register drifted apart, and the person who noticed was the one building from it, not anyone reading it.
plans/20260825_1240-run-pinakes-sweep.md carried, in its build-order discussion, the sentence
"S1, S5, S6, S8, S9 all share one shape — an input the tool accepts and then mishandles, rather
than refuses". Its own findings table, some four hundred lines earlier in the same file, defines S6 as a
message that fires on ordinary deletion — no input is involved anywhere in it. The user
performs a correct, complete deletion and the tool makes a false statement about what it did.
Three things are worth keeping out of it.
The generalisation was the defect, not a typo. Four of the five findings really did share that shape, which is exactly why the fifth was swept in without anyone re-reading it. A summary is a claim about a set, and a claim about a set is only as good as the member you checked last.
The document's two registers had already been recorded as a failure mode here — twice — and it
still recurred inside the file doing the recording. 20260825_1252-plans-sweep-findings.md holds
two registers of the same facts and twelve of its 27 rows stopped describing the tree; the rule
written from that says where a dated snapshot and a ## Build order disagree, the build order
wins. What this instance adds: the two registers can be prose and a table in one file, close
enough that nobody thinks of them as two registers at all.
Building is a stronger read than reading. Nothing about the sentence looks wrong. It reads as a
competent summary, and it survived every pass over this plan until a coder had to decide whether S6
belonged in the same guard as S8 and S9 — a question the sentence answers wrongly and a build
answers correctly. The same pass returned two other things the plan did not have: a third arm of S8
(-k 0 is falsy, so limit or manifest.retrieval.final_k silently substitutes the default) and a
third state of S6 under its decided fix. Cheapest available review of a planning document: give it
to someone who has to act on it.
Corrected in place: the sentence is now a three-mechanism table — a mishandled input (S5, S8, S9, and S1 shipped in 0.32.0), a false reporting predicate (S6), and state that never clears (S7, which the original sentence never mentioned at all).
A decision that did not foresee its third state (20260903 10:24)¶
MEDIUM — an answered decision can still be short a case, and building it is what finds out. D-37 was answered 20260825: option E — gate the move hint on the orphaned sidecar, not the mint count. It is a clear instruction and it is right. Enumerating the states it applies to, before writing anything, turned up three where the decision reasons about two:
| on disk after the change | orphaned sidecar | id minted | hint should fire |
|---|---|---|---|
| file and sidecar both deleted | no | no | no — this was the S6 false positive |
| file moved and edited, sidecar left | yes | yes | yes |
| only the file deleted, sidecar left | yes | no | yes, but nothing was minted |
Option E fixes when the hint fires and is silent on what it says, so the third state passed the new gate and went on printing "so a new id was minted" — false. The wording is implementation, so it was mine to take, and the sentence now reports the state observed rather than the conclusion inferred. A decision that settles a predicate has not thereby settled the sentence the predicate prints.
The same shape, twice in one increment. S8 records -k -1 and -k -100. Enumerating instead
of trusting the record found -k 0: the width is read as limit or manifest.retrieval.final_k, so
a falsy 0 silently meant use the default — the user asks for nothing and receives ten passages.
A third arm of a defect whose record listed two.
The transferable move is the table, not the finding. Both came from writing out every state the mechanism can be in and checking each against the rule, rather than checking the rule against the states someone had already written down. A recorded finding enumerates the cases its finder happened to try, and reads exactly like a complete list.
A fixture named for a scenario it did not build (20260903 10:24)¶
HIGH — a test can be green, correctly named, and modelling a different world. S6 — pnk sync
announcing "moved without its sidecar" and "a new id was minted" on every ordinary deletion
(quoted in halves: the joined sentence is now a retired row) — was
covered by a test called
test_rename_plus_edit_without_the_sidecar_is_reported_as_such. The name is accurate about the
intent. The fixture passed () for the walk's sidecars.
A file moved without its sidecar leaves two halves on disk: the new path carrying no sidecar, and the old path's sidecar carrying no document. Passing no sidecars at all models something else entirely — a file moved and its sidecar deleted, which is indistinguishable from deleting one document and creating another. The test therefore asserted the hint fired in a state where firing is wrong, and it passed, because the code fired the hint on every vanished path. The fixture and the defect agreed with each other, and the agreement read as coverage.
The check that would have caught it is not "is this tested?" but "does the fixture build the state the name describes?" Two seconds of reading the scenario against the assertion, which is the reading nobody does on a green test.
The same increment showed the second half of it. Nothing anywhere asserted the sentence
pnk sync prints for this case — the pairing tests pinned the predicate and stopped there, so the
gate could have been fixed in pairing.py while sync.py went on printing the false sentence, and
every test would have stayed green. A predicate and the words it produces are two surfaces, and
pinning one is not pinning the other. Both are pinned now.
The rollback took the fix with it (20260903 10:24)¶
HIGH — a fix that unit-tests green and does nothing, because a transaction boundary ate it.
S7's clearing was written where it reads best: at the top of _apply, once, before a path is
re-attempted. Repair a document and the row goes. That much was true and the test proved it.
Then running the real thing three times against one broken document produced three rows. The
failure paths in sync call connection.rollback() before recording, and the rollback discards
the DELETE sitting uncommitted in the same transaction. The clear was written, then silently
undone, then a fresh row appended — for every sync, forever. The half of the defect the fix was
also supposed to cure survived it completely, and no unit test I had written could have shown
that, because each one exercised a single sync.
What found it was running the command three times and counting rows — not reasoning about the code, which had already concluded the fix was complete. The repo's own rule says a seam removes an inch of the real path from coverage and that defects concentrate there; a transaction boundary is exactly such a seam, and it is invisible in the source line where the bug appears to live.
Two lessons, and the second is the sharper one. A write inside a block that may roll back is not a write. And "the test is green" answers a narrower question than "the defect is gone" — here they differed by an entire half of the finding.
A third came free, from the same habit. The first version cleared on every action except
Skip, on the reasoning that a skip means nothing was attempted. Running it showed Skip carries
two unrelated meanings: an unreadable document being held, and an ordinary unchanged one.
They want opposite answers — and repairing a hand-edited sidecar back to its original bytes changes
neither content nor sidecar hash, so the repair a user is most likely to perform arrives as
"unchanged", and the fix missed precisely the case it was written for. Skip now says which it
is. One name quietly serving two states costs nothing until something has to tell them apart.
A green battery inherits its author's framing (20260903 10:48)¶
MEDIUM — the mutants a fix's author can imagine are the failure modes that author already guarded against. Row 7's battery section ran 39 mutants and killed 38. Every one of the 38 was written by the person who had just written the fix, and every one died to a test that same person had just written. That is a closed loop, and it reads as proof.
The 39th is the one worth keeping. It widened S6's gate from document.path in orphaned_documents
to the looser document.path in sidecar_by_document, and it survived — which looks like a
missing assertion and was not one. Applied against the full suite it was green on 2398 tests: an
equivalent mutant. The predicate I had introduced as the fix does not, at that program point,
constrain anything the looser one does not.
Three things went wrong at once, and only the first is the kind a gate catches.
- The mutant was equivalent. Not a finding.
- Its
killsselector named a test of a different function —_orphans, which never reaches that gate.tools/batteries/README.mdsays the selector is the half that historically rots; here it was wrong on the day it was written, and a SURVIVED row hid it, because a row that survives never exercises its selector. - The justification I wrote for withdrawing it was false. I wrote that control reaches that
line only for a document being soft-deleted, so its path is necessarily absent from
after.files. The conclusion is true. The reasoning assumes exactly what it needs to prove, and two routes do leave a present path unhandled and drop it into that loop. What actually closes the case is a three-step argument aboutclaimed_by_id: both routes require the id to be claimed by a sidecar beside a present file, and every such sidecar is reached by one of twohandled_ids.add(sidecar.id)sites before the vanished-path loop runs.
Number 3 was caught by a peer trying to refute the withdrawal, and failing. They built a
present-path counterexample, ran it against both the real module and a mutated copy — anchor
asserted to match once, __pycache__ cleared, pinakes.pairing.__file__ checked so that a null
result could not have come from importing the unmutated file — and got an empty result from both.
The refutation failed; the argument it was aimed at was replaced anyway, because attacking it is
what exposed that it had never been load-bearing.
Lesson: a battery written by the author of the fix measures the author's imagination, not the code. Its green is evidence that the fix does what its author meant, never that what they meant was right. Two cheap things buy back most of the gap — run a surviving mutant against the whole suite before believing it is a finding, since an equivalent mutant and a missing assertion are byte-identical in the report; and have someone who did not write the fix try to refute the reasoning rather than re-read the diff, because the reasoning is where the author's framing is load-bearing and the diff is where it is invisible.
Second-order: a SURVIVED row never runs its own selector, so a battery's survivors are exactly the rows whose second half is unverified. The one place the report cannot check itself is the one place it asks to be believed.
See tools/batteries/src-pinakes-pairing.toml,
which carries the withdrawn row's reasoning in place of the row.
A control must be shown able to fail (20260903 12:58)¶
HIGH — and it recurred an hour and a half after the same class was written down, by the same
session, in this tree. Row 8's symlink guard reported a symlink to a real directory as
"resolves to nothing … its target is missing or the link loops" — false on both counts, because
is_file() is False for a directory target exactly as it is for a dangling one.
The part worth keeping is not the bug. It is that a control had been written against precisely this risk, and it could not fail. Its docstring said so out loud:
Without it,
unresolvablecould be populated byis_symlink()alone — which would report every aliased document in a KB that uses links deliberately, andsync.pyalready has a branch explaining thatdocs/alias -> docs/realis a supported shape.
The shape named there is a link to a directory. The shape the body built was
alias.md -> elsewhere.md, a link to a file — which passes is_file() and therefore never
reaches the is_symlink() branch at all. The assertion held for the correct guard and the broken
one alike. Measured, not argued: with the fix reverted, the new directory-symlink test fails and
the other six symlink tests stay green, which is the control demonstrating its own vacuity.
This is [[a-fixture-named-for-a-scenario-it-did-not-build]] again. That fragment is stamped
20260903 10:24 UTC; the commit carrying this instance is 6226b09, 20260903 12:02 UTC
(both read with one instrument, TZ=UTC git log --date=format-local:, because format: renders
in the committer's own offset and mixing the two silently compares different clocks). That fragment's rule — does the fixture
build the state the name describes? — was already written, already in the tree, and did not
transfer, because the second instance did not look like the first: this was a control, added
deliberately against a named risk, in a test file that had just been reviewed. Believing a control
is the failure mode; writing one is not enough.
The repository already owns the remedy in another register. tools/batteries/README.md
requires killing one known-catchable mutant before believing a battery — "a run with no kills is a
broken harness, not a clean bill." A control is the same instrument: it asserts that something
does not happen, so nothing about its green tells you it was ever wired to the code. Revert
the guard and watch the control go red, or it certifies nothing. That is the same test as
"pinned by test X" is a claim about a failing test, applied to the negative case, where it is
easier to skip because there is no fix to revert — you have to reach for the broken version on
purpose.
And it happened a third time in the same increment, caught by the mutation pass rather than by
reading. The battery row that shrinks the closed set — drop "pdf" from SOURCE_TYPES — was
pointed at the control written to catch exactly that, test_every_valid_source_type_is_still_accepted.
It SURVIVED. The control's loop is for source_type in SOURCE_TYPES:, so it iterates the
constant the mutant shrinks: remove a member and the loop simply stops testing it.
The diagnosis matters more than the fix, and tools/batteries/README.md § Reading a SURVIVED row
names both possibilities: the row is a claim about a pair, and either half can be wrong. Here
the coverage was fine and the witness was wrong — with the mutant applied,
tests/test_chunk.py goes red at line 745, because
test_source_types_names_every_value_source_type_can_return derives the expected members from
source_type() over real filenames and never reads the tuple. So the repair was the selector, not
a new test.
Three instances, one shape: an assertion that draws its expectation from the thing it is checking can only ever agree with it. A control looping over the constant, a fixture built from the code's own notion of the state, a docstring describing intent beside code that does something else. None of them can fail, and all three read as coverage. The only instrument that found any of them was one that tried to break the code and watched what did not notice.
A docstring can claim a surface the code never reached (20260903 12:58)¶
HIGH — written as intent, committed as fact, and it was the only record of the gap. The closed-set constant added in row 8 carried this, in my own words:
That is what lets the CLI and the MCP server refuse one at the boundary instead of returning an empty result the user reads as an empty KB.
The guard was wired into pnk search's argparse and nowhere else. pinakes_search built its
Filters from whatever the client sent, so source_type="markdwon" still returned zero passages
under "nothing matched the filters" — the exact defect the increment existed to close, alive on
the surface CLAUDE.md's naming table lists beside the CLI, under a sentence stating it was
closed. eval.py's question loader had it too, where an unrefused typo scores that question zero
recall on every run and reads off an eval table as a retrieval regression rather than as a typo.
The mechanism is ordinary and that is why it is worth a fragment. The docstring was written while the fix was being designed, when "the CLI and the MCP server" was a true description of the plan. The plan then shrank to the surface that was in front of me, and the sentence did not. No gate compares a docstring to the code it sits beside, and a reader has no way to tell a statement of intent from a statement of fact once both are committed.
Two things follow. First, a claim about another module belongs in a test, not a docstring —
the check now lives in one helper the three surfaces call, so the sentence and the code cannot
drift again. Second, when a fix names surfaces, enumerate the constructors rather than the
surfaces: grep -rn 'Filters(' src tools is two seconds and finds every entry point, where
recalling which surfaces exist finds the ones you were already thinking about.
A replace-all edits fixtures you never read (20260903 12:58)¶
MEDIUM — caught by a printed count, which is the only reason it was caught at all. Adding
tests to tests/test_eval.py, I needed a kind: line in three new YAML fixtures and reached for a
whole-file string replace. I printed the occurrence count out of habit rather than suspicion:
occurrences: 6
Three new fixtures, six matches. The other three were pre-existing fixtures belonging to other
tests, and one of them was test_an_unknown_kind_is_refused, whose YAML came out carrying
kind: lexical and kind: multihop — a duplicate key, where YAML silently takes the last.
Every other edit in the increment went through a helper that asserts the anchor matched exactly once and refuses otherwise. This one did not, because it was "just adding a line to my own new tests". The count was printed as information; had it been an assertion, the replace would not have run.
Two things I did not establish, and will not claim: whether the damaged fixtures' tests still
passed — I restored the file before running them, so the honest answer is that I do not know — and
whether git diff --stat would have shown it. What did show it was reading the hunks: git diff
per-hunk, not the summary. A same-line-count replacement moves no totals, so a diffstat is weak
evidence here either way.
The recovery is the part worth reusing: restore the file from HEAD and re-append only your own
block, rather than inspecting and repairing what a replace-all touched. The block was still in
memory as a string, the restore was one command, and the resulting diff — 53 insertions, zero
deletions — is a checkable claim that nothing existing changed, which "I looked and it seemed
fine" is not.
A vague sentence made specific, and false (20260903 12:58)¶
MEDIUM — the fix made the message more precise, which is normally the improvement, and that is how it got worse. The retrieval confidence reason used to say "nothing matched the filters" even when no filter had been passed. Row 8 split it, and the unfiltered arm became "this KB has no active documents to search".
That sentence is false whenever an active document produced zero chunks. _allowed_chunks
joins chunks to documents, so an empty result means no active document produced a chunk, not
no active document. A whitespace-only file syncs cleanly — chunk_document returns nothing for
it, the row is written active regardless — and the user is then told their KB is empty while
pnk doctor counts the document.
The old sentence was unhelpful; the new one was wrong, and confidently. An unhelpful message sends a user to look around. A specific false one sends them to look at the wrong thing, and it carries the authority of having been thought about. When a message is being made more specific, the question to ask is not is this clearer but what does the code actually know here — and the answer was: less than the sentence claimed, because the emptiness of a join is evidence about the join, not about either table.
The comment beside the first fix reasoned explicitly about soft-deleted documents and never
considered the zero-chunk state, which is the shape of the error: the justification enumerated
the states its author had thought of, and read as an enumeration of the states that exist. The
probe now asks the documents table the question the join cannot answer.
The tag points at a commit CI never finished (20260903 13:18)¶
MEDIUM — a release tag can name a commit whose CI legs were cancelled, and nothing in the release
procedure looks. v0.32.2 points at f23b602. That commit's CI run was cancelled 88 seconds
later, when the next landing superseded it, and three of its jobs never completed: check
(light), check (light pdf) and check (light pdf claude) — the three that actually run the test
suite. The run's other eleven jobs succeeded, so gh run list shows it as cancelled rather than
as anything alarming, and docs at the same sha is a clean green.
The release was already published by then. Nothing in docs/RELEASING.md reads CI at the
release commit: the gate before the push is make release-check, which asks about the tag, and the
verification after it asks about the artifact. Both are right and neither covers this.
What made it safe was measured, not assumed. The successor commit 058df49 merges that tree and
its own CI run is green on all three check legs — checked per job, because a run's top-line
conclusion says nothing about which legs ran. And the specific risk was nameable: this release moved
src/pinakes/budget/prices.toml, and the last release that moved that file took both [pdf]
legs red for 44 minutes. Locally those legs skip — pinakes[pdf] is not installed in the primary
checkout — so a green ./check.sh on the release branch could not have seen it. 058df49 carries
the re-stamped as_of and ran both legs green, which is what closes it.
Two things generalise.
A cancelled run is not evidence, and neither is a successor's green run. The successor tests a
different tree — yours plus whatever superseded you. It is good evidence only when you have said
what the difference is and why it cannot mask your defect. Here the difference is another session's
row-8 work in cli, search, serve, sync, chunk and eval, and the leg at risk was a money
trace in tests/test_pdf_trace.py that none of them touches.
The window that produces this is the same window the procedure deliberately uses. Landing and
publishing are separate steps here, and docs/RELEASING.md § A fragment that arrives after the
release commit but before the tag treats the gap as a feature. A peer landing inside that gap is
therefore normal, and cancelling the release commit's CI is what a peer landing inside it does.
Lesson: after pushing a release tag, read the CI run at the tagged commit per job, not per run. If
its check legs were cancelled, name the commit whose green run covers the same tree and say what
differs between them — and say which leg the release's own diff put at risk, because a local
./check.sh skips whatever extras the machine lacks.
A gate that never ran the version it promised (20260903 13:50)¶
HIGH — requires-python = ">=3.13" had been a sentence in a manifest, not a tested claim, for
the life of the project. Nothing in this repository pins a Python: no .python-version, no
setup-python step, and uv sync --frozen resolves to the newest interpreter available. CI's
matrix varies the extras — [light], [light,pdf], [light,pdf,claude] — and never the
interpreter, so every leg ran 3.14. The floor was declared in one file and exercised in none.
What it cost: pnk sync crashed with a raw PermissionError on 3.13 for a symlink into an
unreadable directory, and that crash is present in every published release tested — 0.32.2,
0.32.1, 0.30.0 and 0.25.0 all reproduce it under uvx --python 3.13.
The tell was visible and read as noise. The same commit produced a green gate and a red gate:
./check.sh passed in the worktree and failed in the primary checkout. That asymmetry has a note
in private memory — worktrees get different Pythons; the primary checkout is the 3.13 outlier —
and it was filed there as an inconvenience to route around rather than as the project's only
instrument that ever touched the supported floor. The one machine that disagreed was the one
machine testing what the manifest promised.
Two rules come out of it.
- A version range in a manifest is a claim, and claims get gates. Every declared axis with a
minimum — Python, an OS, a dependency floor — needs one leg that runs at the minimum, or the
minimum is decoration. The new
minimum-pythonjob also asserts the interpreter it got (sys.version_info[:2] == (3, 13)) rather than trusting--python 3.13to have been honoured, because a leg that silently ran 3.14 would be a job going green about the one thing it exists to check — the same failure this fragment is about, one level up. - When two environments disagree about the same commit, that is data, not friction. The instinct is to make the odd one match. The question to ask first is what is it testing that nothing else is, and only then whether to align it. Here, aligning would have destroyed the only signal in the system.
Related: [[pinakes-worktrees-get-different-pythons]], which recorded the divergence and read it as a hazard. It is that too. It was also the coverage.
A measurement with an unnamed population, copied forward three times (20260903 13:50)¶
HIGH — the sentence said measured, and it was. It was measured on one interpreter and written
as though it were about the language. Row 8 left this comment in sync.py:
Measured, not assumed:
exists()swallows thePermissionErrorand returns False.
That was true. It was run, the value came out of the run, and the phrase measured, not assumed
was earned — on Python 3.14. On 3.13 Path.exists() raises, and pyproject.toml promises 3.13.
The claim's population was one interpreter out of the two this project supports, and nothing in
the sentence said so.
Then it was copied into two more places — a test docstring and a mutation-battery row comment — each time as settled fact, each time propagating a scope nobody had ever stated. Three locations, one measurement, zero mentions of what it was measured on.
The failure mode is not laziness, it is that the guard against it was already engaged. Measured, not assumed is the phrase this repository uses to mark a claim as checked, so the sentence arrived pre-defended: a reader looking for unverified assertions skips it, and a reader looking for scope sees a word that promises rigour. A false claim with no evidence gets challenged. A true claim over an unnamed domain does not, because the challenge it invites — did you check? — has already been answered.
The question that catches it is not "did you measure?" but "measured on what?" For anything whose answer can differ across an axis the project spans — interpreter, OS, filesystem, extra, locale — the population belongs in the sentence. The corrected comment now carries a table with both interpreters named and both versions given, because a table cannot omit its own columns the way a sentence can omit its own scope.
Related: [[a-null-result-carries-no-information]] and the 20260831 record of six wrong claims in one day, each a valid inference over a population nobody named. This is that shape again, with the extra twist that the inference here was not merely valid but genuinely executed.
I almost reported an invariant break I had not measured (20260903 13:50)¶
MEDIUM, and the outcome was fine — which is the reason to write it down. Mid-fix, one test
failed on 3.14 but not 3.13. The inference came immediately and it was a good one: at the pairing
site, is_file() returns False on 3.14 instead of raising, so the document's existing sidecar goes
unread, so pairing sees no sidecar, so it mints a fresh ULID for a document that already has
one — ULID permanence, in docs/INVARIANTS.md, broken silently and per-interpreter.
Every step of that chain was plausible. The mechanism was real, the failing test was real, and the conclusion was the most serious class of defect this project recognises. I had a message half drafted to the planner saying so.
I ran it instead. There was no duplicate ULID. 3.14 refuses the document too — via
SidecarError rather than PermissionError. The real divergence was the error class and its
remedy, a genuine defect and a much smaller one.
The lesson is not "measure before asserting", which was already the rule. It is when the rule is hardest to follow. The pull to skip the measurement was strongest precisely because the hypothesis was alarming: an invariant break is urgent, a peer was waiting on an answer, and the reasoning felt like it had already done the work. Severity creates the impression that speed is the responsible choice, and a chain of correct-looking steps feels like evidence because each link is checkable — but the conclusion is only ever as good as the run nobody did.
So: the more serious the claim, the more it earns a measurement rather than exempting itself from one. The escalation-worthy finding is the one to check first, not the one to send first. A concrete marker for next time — I was about to write the words "silently mints a second id", a statement about runtime behaviour, without having observed any runtime behaviour. A sentence describing what the code does at run time and sourced only from reading it is a hypothesis wearing a report's clothes.
Related: [[the-command-ran-the-number-was-typed]] — there, the measurement happened and the figure did not come from it. Here the figure would not have come from anything at all.
The fix stopped at the boundary I reasoned to, not the one the defect had (20260903 14:10)¶
What happened. The crash arrived from pnk sync, so I scoped the fix to the source walk in
sync.py and argued the scope was right: that is where a corpus path gets stat-ed, and the other
41 is_file()/exists() calls in src/ are about paths Pinakes itself created under .pinakes/.
The argument was sound. The scope was wrong. My own adversarial pass found doctor.py failing on
the same state, linkscan.py returning a wrong answer on it, and — worst of the three — an orphan
check that offered --prune for a document sitting on disk, which would have destroyed a permanent
ULID. None of that was visible from where the crash was reported.
Why. I derived the boundary from the symptom's call path instead of enumerating the condition's reach. The condition is "a corpus path this process cannot stat", and the way to find its reach is to list every place a corpus path is stat-ed and ask each one what it does with False. That is a grep and a read, not a deduction — and it is cheaper than the deduction was. Reasoning from one entry point produces a boundary shaped like the entry point.
What it cost, and what it nearly cost. Three commits instead of one, which is nothing. But the
--prune bug is interpreter-independent and predates this branch: it was reachable on every
supported Python, and a fix that had stopped where my reasoning stopped would have shipped a
version-independence claim while leaving a data-destroying prompt in place one file away. The
review found it; the reasoning had excluded the file it was in.
The rule. When a fix is for a condition rather than a line, establish the scope by
enumeration and say so — grep the predicate, read each site, record what each does when the
answer is False. Then state the boundary as a set you looked at, not as a category you inferred.
A category is a claim about files you did not open.
And check what False means at each site before reusing the helper. Two of the four sites wanted
the new version-independent is_regular_file; the orphan check wanted os.path.lexists, because
there False means nothing is here and it had been reading it as nothing readable is here. A
shared helper makes a class of bug go away and a class of bug easy to write: the sites that must
distinguish absent from unreachable are exactly the ones a sweep will quietly get wrong.
A two-way selector that can only fall one way (20260903 15:23)¶
What happened. Verifying the published 0.32.3 fix for the --prune bug, the check was
grep -qi 'orphan' && echo "ORPHAN REPORTED" || echo "the id is safe". On the fixed wheel
pnk doctor printed OK orphaned sidecars: none. The word orphaned is in that line, so the
selector matched, and the harness printed ** ORPHAN REPORTED — a live document's id is offered
to --prune ** about a release that had just been shown correct — with the output it had misread
printed two lines above the verdict.
Why it is not a typo. The selector had two branches and only one of them could ever be reached
by evidence. grep -q matching is a claim; grep -q not matching is the absence of a claim, and
the || branch spends it as though it were the opposite claim. So every failure mode of the
instrument — a wrong pattern, a command that died early, an empty string, a renamed message —
lands in the same branch as a genuine pass. &&/|| on a matcher is a one-way test wearing
the shape of a two-way one, and it is the shape most of my quick verification snippets have.
Why nothing caught it. The check ran, printed, and exited 0. There is no gate for the measurement was performed and the number was typed anyway, because the checking step is present. It was caught only because the surrounding two lines of raw output happened to be printed beside the verdict, and a human-shaped read of them disagreed. Had the harness been tidier — verdict only, no evidence — it would have shipped a false negative about the most severe bug of the day, in the direction that says still broken, which is the direction that gets acted on.
The fix, and it generalises past this check. State both expected forms and refuse to guess when neither appears:
if printf '%s' "$out" | grep -qE 'WARN +orphaned sidecars: [1-9]'; then verdict=BROKEN
elif printf '%s' "$out" | grep -qE 'OK +orphaned sidecars: none'; then verdict=FIXED
else verdict=INCONCLUSIVE
fi
Three outcomes, not two. INCONCLUSIVE is the one that carries the information the || branch was
destroying: the instrument did not recognise what it was looking at. It is also the branch that
fires when someone rewords the message, which is when a silent selector is most likely to be wrong
and least likely to be doubted.
The rule. A selector deciding between two states must be able to see both. Write the positive
pattern and the negative pattern, and make the fall-through say so — never match && A || B. This
sits directly beside two rules already written down here: a null result carries no information
until the selector is shown able to fire, and a claim resting on an instrument you chose must
state the selector. This is the third face of the same coin, and the cheapest to check: read your
own snippet and ask which branch a broken instrument lands in. If the answer is "the good one", the
snippet cannot fail visibly.
Two more instrument failures in the same twenty minutes, both of which printed a clean-looking
pass. A hand-written pinakes.toml was refused for a missing key, so the crash probe never
reached the crash. And with the embedding backend absent, pnk sync exited before the walk it was
built to exercise, printing no PermissionError having tested nothing — the same class as a fixture
whose sidecar is missing, so the loop that raises is never entered. Three in one session says the
failure mode is the harness, not the day: every one of them stopped short of the code under test
and reported the absence of a symptom as the absence of the defect.
A fixture that never reached the code under test (20260903 16:10)¶
What happened. A peer handed over a reproduction for row 31's first shape: make the [sources]
root a symlink into a blocked ancestor — kb/docs -> ../blocked/realdocs, with blocked at
0o000 — and observe that Path.is_dir() raises on 3.13 while returning False on 3.14. It came
with the interpreter divergence measured on both versions, and with a named source line. Rebuilding
it as a fixture, it never ran: manifest._check_include_containment refuses a root that resolves
outside the KB, so the manifest fails to load and the walk is never reached at all.
What was wrong, and what was not. The interpreter divergence was real. What did not survive was
everything downstream of the fixture: the reachable shape needs the symlink target inside the
KB (kb/docs -> store/realdocs, kb/store at 0o000), and with that shape the 3.13 raise is at
is_dir() and not, as reported, one line earlier at .resolve() — resolve() is non-strict here
and does not stat. A precise, correctly-measured claim about the wrong tree.
Why a peer's reproduction is the easy one to accept. It arrives already carrying the things that normally warrant trust: a measurement, both interpreters, a file and a line number. None of those is evidence about whether the state it builds is reachable by the code under test, and that is the one property a reproduction has to have. A fixture that errors before the code under test can still print a plausible failure — a different failure — and the reading of it is confirmatory either way.
The check that catches it, and it is one line. Assert something the fixture must have reached
before believing what it reports. Here that is the state right after the setup: the index contained
store/realdocs/a.md, which is only true if the manifest loaded and the walk ran and keyed the
documents by the resolved root. The test now carries that assertion with a message explaining what
it is for, rather than beginning at the failure the fixture is meant to produce. Opening a
reproduction with a proof that it got as far as the code under test costs one assertion and is the
difference between a regression test and a fixture that would pass on an empty function.
What was right about the exchange. The peer had already applied this to itself once that day — correcting a claim of its own that was true of its fixture and false of the language — and said so in the handover. That is what made checking the fixture the obvious next step rather than a slight. The rule a peer's claim is not evidence until you have checked it survived being handed a claim that was 80% correct, which is the hard case: the wrong 20% was load-bearing, and every signal of rigour attached to the 80%.
The positive counterpart, done for this increment rather than assumed. The rule "unit tests
prove a component honours a parameter — only running proves the parameter arrives" is the same
statement from the other side, so the fix was exercised the way a user meets it before landing: a
real pnk sync over a 30-document KB with docs/vault at chmod 000. It printed
failed: docs/vault: directory could not be entered: Permission denied. with the remedy,
0 removed and 30 unchanged, and exited 1; pnk doctor then exited 0 with nothing to say
about orphans or retired rows, and pnk search "appraisal criteria" answered from the held
document inside the locked directory. Every one of those is asserted by a test as well. The run
is what shows the assertions are about the command the user types.
A hook that could not fire for half the class (20260903 16:10)¶
What happened. The decided fix for row 31 was ask the filesystem instead of inferring: replace
a silent root.glob(pattern) with a walk carrying an os.walk error hook, collect the directories
it could not read, and hold the documents under them. The decision named two fixtures — an
unreadable root and an unreadable subdirectory — and both were measured on both interpreters before
it was taken. It was still insufficient, and the gap was not in the reasoning.
The measurement that found it. Before writing anything I enumerated the modes, not the cases:
0o000, 0o100, 0o400, 0o500 on a subdirectory, asking each of root.glob("**/*.md"),
os.walk(onerror=…) and os.path.isfile what it said. Four rows, one script. The 0o400 row is
the one nobody had:
| mode | glob yields | onerror fires |
os.path.isfile |
|---|---|---|---|
0o000 |
nothing | yes | False |
0o100 |
nothing | yes | True |
0o400 |
the entry | no | False |
0o500 |
the entry | no | True |
At 0o400 the directory lists and cannot be entered. scandir succeeds, so no error is raised
for any hook to receive; the glob hands back the name; and every stat on that name then fails one
at a time. So the decided mechanism — an error hook — is structurally blind to it, and the walk was
still deleting the documents underneath at exit 0. On Python 3.13 it did not even do that quietly:
Path.is_symlink() lstats, lstat needs +x on the parent, and the next line raised.
Why the decision could not have found it. Both named fixtures make the directory unlistable, and unlistable is the only state that fires a hook. A decision reasoned from two instances of one state will specify an instrument that covers exactly that state, and the specification then reads as complete because every fixture in it passes. The gap is invisible from inside the case list; it is only visible from the axis the cases are points on.
The generalisable move. When a fix is specified in terms of an instrument — a hook, a probe, a callback — enumerate the states of the thing being instrumented and ask the instrument about each one, before writing the fix. Not more fixtures of the same shape: the axis. Here the axis was four permission bits and cost one script; the two cases in the decision were both the same point on it. The question that finds this is "what can this instrument not see?", and it has to be asked while the instrument is still a choice.
What it changed in the build. The fix is two halves rather than one, and they are not
alternatives: paths.unreadable_directories collects what the hook can see, and paths.unreachable
catches the rest one candidate at a time, with the walk recording that candidate's parent —
always the culprit, because a glob cannot descend past an untraversable directory, so every entry it
still yields from one is a direct child. A test pins the limit itself
(test_unreadable_directories_cannot_see_a_directory_that_lists_but_cannot_be_entered), because the
per-candidate half looks redundant beside the collector and the next reader will delete it
otherwise. A stated limit needs a test as much as a behaviour does — it is the only thing that
turns red when someone acts on the belief that one half is enough.
A postscript, because it is the same lesson pointed at the test harness. The mutation battery
for this work reads 57 of 57 killed on 3.13, and 56 of 57 on 3.14 — one survivor, the row that
reverts doctor's directory guard to the pathlib spelling. That row is not a gap. Path.is_dir()
raises on 3.13 and returns False on 3.14, so on the newer interpreter the mutant merely skips the
root and the test's assertions still hold. The row's own comment said so before the run, which
is the only reason the survivor is readable as a prediction rather than as a hole; written
afterwards it would be indistinguishable from an excuse. An instrument that cannot see a case has
to say so in advance — which is exactly what the 0o400 row above is about, one layer down.
Row 32 — a ruling that was wrong on a premise, and a fake that outlived its branch (20260904 02:02)¶
HIGH — a decision can be wrong because of a fact, and the fix is to measure the decision, not to
re-argue it. The plan ruled paths.unreachable as the discriminator for both of this
increment's fixes, and stated that it would "restore 3.14 to what 3.13 already does". It would not.
unreachable is lstat-based, so on a symlink pointing into an unreadable directory it answers
False on both interpreters. Building the ruling as written would have left 3.14 broken and
regressed 3.13, moving that document from paid extraction unreadable into neither list —
silently dropping a row 3.13 reports today, on the very shape that produced the ULID-deletion bug.
The obvious repair over-fires in the other direction: a naive os.stat sibling answers True for
a symlink loop, where 3.13 answers False. Neither candidate alone is parity. What settled it was
a table of seven shapes on two pinned interpreters, with the controls — absent, dangling, loop,
ordinary file — in the same run as the cases. The ruling was re-ruled within the hour on that
evidence. The cost of measuring first was about twenty minutes; the cost of not doing it was a
regression on the interpreter this project declares as its floor.
HIGH — a fake pinned to an implementation detail fails loudly when the detail moves, and goes
silent when the detail's behaviour moves. test_an_unreadable_directory_is_refused_rather_than_
crashing monkeypatched Path.is_file to raise, and passed on both interpreters — while on 3.14
the real Path.is_file() raises for neither errno the test's own comment names, so the
production branch it certified was dead. The test was measuring its own fixture. When the fix moved
the call to paths.unreachable_through_links, the same test went red immediately, because the fake
no longer intercepted anything. Both failures are the same seam; only the noise differs, and
the quiet one had been shipping. The repair was to move the fake down to os.stat, the OS
boundary, and to add a real chmod test beside it — the injected one now earns its place on the
errno no permission fixture can produce and on running as root, where the chmod fixture skips.
MEDIUM — "the library does X" is often a measurement of one interpreter, written up as a fact
about the library. The docstring being fixed here claimed pathlib "ignores ENOENT, ENOTDIR,
EBADF and ELOOP … but nothing else". True on 3.13, and on 3.14 Path.is_file() swallows
everything, EACCES and ENAMETOOLONG included. That single sentence is why an except OSError
clause sat dead in production and why a test sat green on top of it. The replacement constant is
named for what 3.13 does, not for what pathlib is believed to do, and its four members were
measured on both interpreters rather than read out of CPython — with the one member whose fixture
is not portable marked as measured-but-not-pinned instead of quietly asserted.
MEDIUM — an audit's list decays, and one of these had already been fixed by a neighbouring
branch. Of the survivors this row carried, doctor.py:411 was cured in 514fe46 — row 31's own
commit, in passing — while the row still described it as live and named the release that had walked
past it. Checking git log -S on the exact line took a minute and removed an item from the queue.
An unverified finding ages in both directions: it can be worse than raised, as the link one was,
or already gone.
A gate I read but did not obey (20260904 02:12)¶
HIGH — I landed over a red ./check.sh, and the commit message says it was green.
The chain was ./check.sh > log 2>&1; echo "CHECK=$?" … git add -A && git commit. A ; where an
&& belonged. The exit status was captured, printed as CHECK=1, and then ignored by every
command after it. f857e39 reached origin/main carrying the sentence "check.sh green, make docs
green", which was false when it was written.
This repository already has the rule, in CLAUDE.md, and I have been quoting it at other
sessions all day: a gate is only a gate when its exit status is what the next command reads. The
canonical failure it names is check | tail && git commit, where tail's success masks the
checker's failure. Mine is one step cruder — I did not mask the status, I printed it and carried
on. Having the number on screen is not the same as branching on it, and a chain that reports a
failure it does not act on reads exactly like one that passed.
The failure itself was environmental, and that is the part that makes this worth writing.
test_a_target_outside_the_repository_is_refused returned -15 — SIGTERM — where it asserts 1.
The subprocess was killed, not wrong. Another session was running a full suite and a mutation
battery on the same machine at the same time. Re-run on main: that test passes, its whole file
passes 70/70, and ./check.sh is green at 2452 passed. So the tree was never broken — which is
precisely why the process failure is worth recording rather than quietly fixed. A red gate that
turns out to be noise is the cheapest possible lesson; the same ; on a real failure lands the
defect.
Two things to carry.
A signal-killed test is not a failing test, and the report cannot tell you which. assert -15 ==
1 reads as an assertion failure. Only the value says otherwise, and only if you know that a
negative return code is a signal. Under concurrent load on a shared machine this will recur, so
read the number before diagnosing the code.
Verify before excusing. The environmental explanation is the convenient one, and this project's
own record is full of convenient explanations that were wrong. It was checked three ways — the
single test, its whole file, and a full ./check.sh on main — before being written down as noise.
The order matters: if any of those had been red, this fragment would have been a defect report
instead.
Lesson: ; between a gate and a commit is the same defect as piping the gate through tail, and
it is harder to see because the failure is printed rather than hidden. Chain with &&, or read the
log before the commit — never both a printed status and an unconditional next step.
Row 38 — a test that proved nothing, and a contract "verified" on one interpreter (20260904 02:55)¶
HIGH — a test can stop testing anything without ever going red, and the only way to know is to
disable its instrument and watch it still pass. test_an_unreadable_linked_kb_path_is_a_warning_not_a_traceback
monkeypatched Path.is_file to raise, and asserted that pnk doctor reported a WARN. Its fixture
built the partner directory without a pinakes.toml — so the WARN it asserted came from the
partner genuinely not being a KB, and the injected PermissionError decided nothing at all. I did
not infer this: I disabled the monkeypatch line and re-ran, and the test passed unchanged. It had
been green for the wrong reason, and no gate anywhere can see that, because a vacuous test and a
sound one are the same colour. The replacement uses a real, synced partner KB behind a real
chmod, so the permission is the only thing that can make it fail.
HIGH — "verified" in a docstring is a claim about the machine it was run on.
why_not_a_kb's docstring argued that it may raise and that its callers guard it, naming
exists(), is_symlink() and is_dir() as raising on an unreadable parent, and marked that
verified. It was verified on one interpreter. On 3.14 those calls return False, so nothing
raised, no except OSError fired, and three commands printed "no such directory" about a
partner on disk. The docstring did not merely go stale — it was load-bearing, because three
call sites carried comments justifying their guards by pointing at it. One sentence measured once
propagated into three modules.
MEDIUM — the argument against a total contract was the thing worth re-reading, not the code.
That docstring gave an explicit reason it could not be total: there is no answer it could return
for "I could not tell" that a caller would not have to branch on anyway. That was false the whole
time, and its own callers proved it — every one of them was already printing exc.strerror, which
is exactly the answer. The same module argues the general case twelve hundred lines up, in
resolve_path: a function three call sites each had to remember to guard is a function with the
wrong contract. The counter-argument to a rule was sitting in the same file as the rule.
MEDIUM — the same seam failed for the fifth time in one increment. Five tests faked
Path.is_file because that was the spelling the production code happened to use. Each went red the
moment the call moved to a version-independent one — which is the loud failure. The quiet one had
already happened: on 3.14 the real call raises for neither errno these tests name, so the branch
each certified was dead while the fake entered it and they passed on both interpreters. Fake the
OS boundary, not the caller's spelling of it.
Row 40 — a count whose meaning depended on something nobody recorded (20260904 03:14)¶
HIGH — an equivalent mutant and an unpinned assertion are byte-identical in the report, and I
read one as the other. A committed row came back SURVIVED and I reported it as an unkilled mutant
and as a coverage gap. Both halves were wrong. On the newer interpreter the mutated and unmutated
programs behave identically — both spellings of the predicate answer False — so nothing could
kill it, and the silence I called a gap is produced by the fixed code too, which makes it a
different open question rather than a hole in this battery. What I actually had was narrower and
still worth the row: the same battery gives two different counts on two interpreters, and the
report never said which one you were reading.
HIGH — the fix asked for was the wrong fix, and building it literally would have been worse than
building nothing. The row said to print sys.version. mutate.py's own sys.version is the
launcher's, and the documented invocation is python3 tools/mutate.py — the system interpreter —
while the tests run under uv run --frozen pytest, the project venv. Measured by making them
differ on purpose: launcher 3.14.7, venv 3.13.15. The literal implementation would have printed
3.14.7 beside counts produced entirely by 3.13.15 — an answer that reads as measured and
names a Python no test touched. A wrong number in a sentence that looks like evidence is worse
than an absent one, which is why the unprobeable case says could not identify instead of
falling back.
MEDIUM — placement is delivery. The pytest = … command has been printed in this tool's
header the whole time and never answered this question, because the header is what gets left in the
terminal and the counts are what gets pasted into a commit message. The new line goes beside the
counts, and a mutant pins that ordering — a correct sentence in the wrong place is indistinguishable
from an absent one at the moment somebody needs it.
MEDIUM — the honest way to report an instrument you cannot read. Two command shapes are probed
because two are documented; anything else returns nothing rather than a guess. That is the stance
this tool already takes towards a JUnit report it cannot parse — the outcome is unknown rather
than SURVIVED — and applying it to the interpreter as well cost one if and removed a whole class
of confidently wrong report.
Row 39 — I tested the copy that did not contain the change, and broke main doing it (20260904 08:30)¶
HIGH — a smoke test run against the wrong binary reads exactly like a passing one. I had just
written the guard that refuses to land an ungated tree, and I wanted to watch it refuse. So I
committed it as WIP and ran python3 tools/land.py <my branch> from the primary checkout,
expecting a refusal. It landed, and pushed — because land.py executes from the primary
checkout, which still held main's ungated copy. I had written the sentence "the gate runs in
the branch's worktree and this refusal runs here" into that same file minutes earlier. The
instrument did not contain the thing under test, which is the identical shape as the vacuous test
found hours before: an injection that no longer intercepted anything, and a result that read as
informative because nothing about the output says which code produced it.
HIGH — the change broke main, and the way it broke is the argument for the change. Six
tests/test_land.py tests went red immediately: their scratch repositories never run ./check.sh,
so the new guard correctly refused every landing they assert. The tests were right, the guard was
right, and they had never been introduced to each other. What actually put a red tree on
origin/main was not the guard — it was landing without running ./check.sh at all, which is
precisely what the guard makes impossible. It could not stop me because it was not yet on main:
this guard's own landing is the one landing it cannot police. That bootstrapping hole is worth
stating rather than smiling at, because it is the general case — a gate can never cover the commit
that introduces it, so that commit needs the discipline the gate is replacing.
HIGH — the planner caught a hole that would have made the guard blind exactly where it matters,
and the measurement was worse than the report. The first design keyed the marker to the branch's
tree. But git merge --no-ff combines the branch with a main that may have moved, and the result
is then neither side's tree. Reported as one merge in three; measured across all four merges
available, it was two of four — and both were mine. So the guard would have passed while a tree
nobody had gated reached origin/main: the a clean auto-merge is not a correct merge case, with
the guard asleep in the one situation it exists for. git merge-tree --write-tree computes the
merge result without performing it, touching neither the working tree nor HEAD, and predicted the
real tree in all four — so the check happens before the merge rather than after it with a reset
to undo.
MEDIUM — the absence of an escape hatch is a feature, so it is asserted rather than assumed. A
flaky or environmental red now blocks a landing; a suite killed by machine contention did exactly
that the same night, correctly. The first person to meet that at 3am will want --no-gate, and a
test now fails if one appears. The reason it does not exist is that the rule being skippable is
what put the guard here in the first place.
Row 41 — four instruments for one audit, and the fix was already written down (20260904 08:54)¶
The audit itself was one afternoon's work. Getting an instrument that could be trusted took four attempts, and every failure was the audit's own subject turned on itself: a measuring device that did not contain what it claimed to measure.
HIGH — the decisive bug was a rule this repository already states, in a context I did not
recognise as the same machine. CLAUDE.md tells a mutation harness to clear the module's
__pycache__ after writing and after restoring, because CPython invalidates bytecode on
(mtime-to-the-second, size). This audit edits a test file, runs pytest, restores, and repeats —
many times a second — which is precisely that shape. I did not apply the rule, because I was
building "an audit" rather than "a mutation battery". Two of fifteen sites then returned a
different verdict on repeat, and one site was reported vacuous by a batch run while three
separate single-site runs called it sound. Clearing __pycache__ took the instability to zero of
fifteen. A rule filed under the wrong noun is a rule you will break.
HIGH — "still passes with its instrument disabled" is three findings wearing one label, and only
one of them is a defect. The probe cannot, by itself, tell apart: (a) a test whose assertions
do not depend on the injected condition at all; (b) a test where the real environment produces
that condition anyway, so the fake is redundant; (c) a mis-attributed site, where the probe
disabled an injection and then ran a test that never used it. All three print the same word. Case
(c) was my first instrument attributing injections inside helpers to whatever test happened to
sit above them. Case (b) is real and stayed: a 300-character filename raises ENAMETOOLONG on
this machine for real — NAME_MAX is 255, measured — so that injection decides nothing here,
while the test's own comment records the opposite on CI. That site cannot be ruled from this
machine at all, and saying so is the result; picking either answer would have been an invention.
MEDIUM — the count moved 5 → 3 → 2 across instruments, and only the last one is reportable. Every intermediate number was a true statement about a broken measurement. The discipline that saved it was cheap and mechanical: probe every site twice and report only verdicts that agree, then cross-check the flagged ones by hand. The one genuine finding survived all four instruments.
MEDIUM — what the audit found, stated against its own trigger rather than against a preference.
Fifteen injection sites on pathlib/os/paths predicates; one genuinely vacuous test, now
fixed. The trigger for writing a general rule was more than one further vacuous test. One is not
more than one, so no rule was written — deliberately, because a rule invented from a single
instance is the ceremony this repository keeps warning about, and because the honest reading of a
boundary result is to report the boundary rather than round toward the outcome that produces a
deliverable.
Row 43 — I measured the function and made a claim about the command (20260904 09:38)¶
HIGH — an isolated measurement is evidence about what you called, never about what the user
runs, and the gap between those two is where I put a false finding. Row 36 sent me to probe the
pathlib predicate sites nobody had looked at. I called two of them directly, watched
PermissionError on 3.13 and silence on 3.14, and reported two live defects on the declared
floor — including "ends pnk sync in a raw traceback". The divergence was real and
reproducible. The consequence was invented. Measured afterwards, against the real command:
pnk sync on that state produces byte-identical reports on both interpreters, both call sites
of the first function sit inside except (PinakesError, OSError), and the second is unreachable
because manifest.load refuses an untraversable root before apply ever runs. Reachable count:
zero. The planner filed a row and ranked it above a gate design on that report.
HIGH — the thing that caught it was the test failing to fail. I wrote the end-to-end test first. It passed. Then I reverted the one-word fix and ran it again — it passed again. A test green with and without the change under test pins nothing, and that is what sent me looking for the call path instead of banking the finding. That is row 41's own method, turned on row 41's author about an hour after it was written up, and it is the second time in one day the same check has caught something: once in the audit, once in the auditor.
MEDIUM — the strongest argument for closing a class rather than fixing instances came from
being wrong. The reason my sync.py finding is unreachable is row 31's own guard:
unreadable_directories refuses the directory at the walk, so the per-file path is never entered.
A fix that landed the same morning, one level above the site, had already closed the class the site
belongs to. Nobody planned that overlap and it is the clearest evidence for the ordering the queue
keeps arguing about.
MEDIUM — what a hygiene change has to say about itself to survive. Both lines still changed —
one word each — because the codebase has decided paths.py owns this question and these were the
last holdouts on user paths. But each docstring now states plainly that no production path
reaches it today, how that was measured, and what would change it: the walk guard narrowing,
or a caller dropping its except. Without that last clause the next reader deletes the change as
pointless, and without the first two they inherit my false claim in a comment.
MEDIUM — a test that discriminates on one interpreter should say so in its own docstring. Both
new tests are red on 3.13 without their fix and green on 3.14 either way, because the unfixed
spelling already returned False there. Stated in the tests rather than left for someone to
discover when a 3.14-only run reports them as covering something.
Row 42 — six ways an instrument reports success, five of them mine (20260904 10:28)¶
Scope, first, because it decides what the rest is worth. Everything below was measured in this
session, after a context clear at 09:54 UTC. I have not re-derived the row 41 audit, the floor
audit's 18 → 8 → 5 chain, or the six-mutant episode, all of which happened in this session's
previous context. A successor asked to write those up refused on the same grounds and that
refusal was recorded as correct; repeating it here rather than reconstructing them from a peer's
summary. The evidence for those lives in plans/20260901_1148-clear-the-user-facing-list.md row 32,
in the retrospective fragments already spliced into this document, and — measured
independently by the planner during this session, under its own name rather than on my
testimony — in
plans/20260904_1027-development-paused-handover-and-evidence.md,
which is the document a resuming session should read first.
HIGH — the instrument added itself to the population it was measuring. The audit greps every
tests/test_*.py for monkeypatch.setattr(Path|os|paths, …) on a line ending in ). A test file
about injections writes exactly that as fixture data, so mine joined the set: 15 sites became
17, and probing a "site" that was a string literal edited a line inside a string, broke the file
it lived in, and returned INCONCLUSIVE — the whole run went red over a site that does not
exist. tests/test_mutate.py already carries a warning about the identical trap against
test_verification.py's ^def (\w+) scan. The trap was documented, in this repository, for a
different gate, and I walked into it anyway. A rule filed under the name of one instrument is a
rule you will break with the next one — which is the same sentence row 41's own retrospective
wrote about __pycache__, one tool over and one day earlier.
HIGH — my first mutant survived, and it was the mutant that was wrong. Testing the guard above,
I wrote a poisoning line that matched the regex but did not end in ), which sites() also
requires. It changed nothing, the test stayed green, and "the guard is pinned" was one keystroke
away from being recorded. A mutant that cannot kill certifies the test, not the code, and the
only thing that caught it was noticing that a guard I had just watched fail by hand had not failed
under its own mutant.
HIGH — a report that named the environment the run finished in. environment() was called
after the probe loop. At 10:23 the audit reported probed under Python 3.13.15; at 10:24, in the
same directory with no uv sync between them, the next command answered 3.14.7. It is now read
before the loop and again after, the report names the before value, and a run whose environment
moved is UNATTRIBUTABLE. The mechanism was not established — the flip did not reproduce under
a controlled sequence of uv sync --python 3.13, uv run, a pytest run and a clear_pycache — so
this is a guard against an observed instability, not a fix for a diagnosed one, and both the code
comment and this paragraph say so rather than inventing a cause.
MEDIUM — a comment that stated the intent its code did not implement. At the classification
site: "A skipped test also exits 0, and a skip is not a pass" — followed by a check for
no tests ran and 0 passed only. pytest prints 1 skipped, which matches neither, so an
all-skipped selector fell through to VACUOUS with the exit status agreeing: a false finding,
in the one direction that matters, because a sound verdict withholds a finding while a VACUOUS
one is what a person then acts on by deleting a fake. The honest half of the scope: no owning
test is geteuid-guarded today — those guards sit on their non-injecting neighbours — so this
was a latent misclassification and not a live wrong verdict. It is the same shape as the
why_not_a_kb docstring that asserted the opposite of its code: prose next to code is not
evidence about the code, and it is read as if it were.
MEDIUM — an empty result set that certified a clean one. With no sites collected the report
read 0 sites · 0 vacuous · 0 not ruled and exited 0. A reformat wrapping one monkeypatch.setattr
across two lines is enough to produce it. Now a refusal, with the floor asserted by value —
--min-sites 1 restores the hole in full, which is wheel_import_gate.py's own --min-modules 1
lesson arriving one tool later.
MEDIUM — I left a fake in the process-global subprocess module. module.subprocess.run = spy
does not patch the tool's view of subprocess; module.subprocess is the module, so three
tests replaced subprocess.run for the whole session and never restored it. They passed because
nothing after them in that file shells out. In a file whose entire subject is instruments that
quietly stop measuring, this is the joke writing itself.
MEDIUM — three timestamps I composed instead of reading, in a branch about measurement. A test
docstring claimed 20260904 10:57 UTC while the clock said 10:23 — a measurement dated in the
future. Two commit messages claimed 10:12/10:22 and 10:31 for runs whose own log artefacts say
10:01, 10:03 and ~10:05. All three were plausible, none was read. The rule read the clock, never
compose it is in CLAUDE.md twice; what defeats it is that a wrong time never looks wrong. The
fix that worked was mechanical: take the time off the artefact the run left behind
(TZ=UTC stat on the log), never off memory of when it happened.
A note on instruments, since two of mine were wrong today. Reading the workflow journal I
looked for a key named value; the key is result. My reader printed None for every agent and I
briefly believed two design agents had died — they had returned complete answers. A null result
carries no information until the reader is shown able to fire, and I had not shown it. The other:
TZ=UTC does not make git log --date=format: render UTC, and format-local renders in the
machine's zone — here IST, two hours off. Every time I quote below came from TZ=UTC plus
--date=format-local, verified against date -u in the same command.
What the ownership boundary actually cost, measured rather than recalled. It bound twice today.
Once for docs/VERIFICATION.md, where the narrow exception applies and I added five rows directly —
cost: zero. Once for row 42's own specification, which contained a self-contradiction
("the stable verdict set is empty" alongside "a VACUOUS row is a finding to read, not a build
to fail", when VACUOUS is a stable verdict). I could not fix the row; I stated the reading I
would build on and did not block. Cost: one message round-trip, and I never stopped working.
The planner corrected the row inside that window. So on today's evidence the boundary cost is
write-latency on a shared document and not throughput — one data point, on a day with one live
peer, and it should not be generalised past that.
ty and pyright disagreed in both directions on one file, which is the argument for running
both. ty rejected module.attr = … on a dynamically loaded ModuleType (13 diagnostics) where
pyright was clean; pyright rejected untyped lambda parameters where ty was clean. check.sh runs
ty first, so the suite never ran at all and the gate was red for a reason no test could show.
CLAUDE.md calls ty "a fast pre-check, never the gate" — today it caught something pyright did
not, and chasing its complaint is what surfaced the subprocess leak above. That does not make it
a gate; it makes the pairing load-bearing.
The prediction, written down before the run, because a run nobody could predict is a run nobody
can interpret. Fifteen sites were predicted independently — one reader per site, each reading the
owning test and the production code it drives — and the set is unanimous: fourteen sound, one
VACUOUS (tests/test_cli_link.py:617), every one at high confidence, and not one judged
platform- or interpreter-dependent. So the expected Linux result is identical to the macOS
one, and any difference is a finding.
The mechanism offered for :617, which is the site row 42 exists for: add() -> source_sidecar()
-> _document_in(), whose joined.parent.resolve() resolves only the parent, so the 300-character
component reaches _is_file() intact; that calls paths.unreachable_through_links(), whose body is
a bare os.stat() — the real syscall once the injection is neutralised. ENAMETOOLONG is
deliberately absent from _NOT_THERE, so the refusal is True, _is_file re-stats to fetch
strerror, and both assertions still hold. Verified here, on macOS: errno.ENAMETOOLONG is 63,
os.strerror(63) is exactly "File name too long", _NOT_THERE is
{ENOENT, EBADF, ENOTDIR, ELOOP} with ENAMETOOLONG absent, and both pinakes.paths and
pinakes.link bind the os module, so the injection really does intercept both call sites when
armed. Not verified here, and this is the half the Linux run is for: that Linux's errno 36 maps
to the same strerror string, and that the VFS rejects an over-long component by length before any
existence check.
Why the test's comment would then be stale rather than wrong. It records that on CI "the long
name simply was not a document" — and that describes the pre-refactor path, built on
Path.is_file(), whose 3.14 error-swallowing could make ENAMETOOLONG vanish silently. The current
code bypasses pathlib entirely and calls os.stat directly, twice. So if the site does come back
VACUOUS on Linux, the comment is not to be deleted: what changed is that
paths.unreachable_through_links did not exist when it was written.
A measured data point on review efficacy, which the process analysis lists as not established. One fan-out ran against this branch: 19 agents, 0 errors, 0 empty results — three design readers on different framings, fifteen per-site predictors, one judge over their output. What it produced, counted rather than characterised:
| count | what it was | |
|---|---|---|
| Real defects in my landed work | 2 | the path filter silent for the tool's own motivating failure; a 3.13/3.14 matrix inverting the repo's settled idiom |
| Confirmed by my own independent check before acting | 2 of 2 | grepped each faked name across src/pinakes/; read ci.yml:89-92 verbatim |
| False or stale raises | 2 | two designers built stdout-grep gates for contracts the tool had already grown, and flagged a 16th site that was fixed in the working tree — both were reading a stale checkout |
| Predictions produced | 15 | unanimous, and that uniformity is itself a weakness the judge named |
Both real defects were mine, both were in code I had already gated green, and neither was the kind
of thing a test would catch — one is a trigger that fires too rarely, the other a comment that
argues against the repository's own precedent. The paths: one is the sharper of the two: not a
false green but a false silence. A green run is at least a run; a filter that never fires
produces nothing to read, and nothing to read looks exactly like nothing to worry about. That is the
same failure shape as an empty site list reporting 0 not ruled, one layer out — the third instance
of it in this one branch.
And the two stale raises are the honest cost. Both designers were reading the tree as it stood when they were launched, and I had landed three commits while they read. A fan-out over a moving tree pays for it in exactly this way, and the fix is not more reviewers: it is launching the readers against a named commit rather than a working directory. Nothing in the harness made that mistake visible — I found it only because the judge, launched later, contradicted them.
What is not established, stated so nobody infers it. The Linux legs had not run when this was
written — push is restricted to main, so no branch push fires them and the merge commit is what
does. Everything above is a prediction plus a macOS measurement. One run settles it, and the
row carries what would falsify it.
Row 42 — the Linux run, and the prediction it confirmed (20260904 10:41)¶
The run happened, and it matched the prediction exactly. injection-audit run 33864216950,
on main at e3d82dd, ubuntu-latest:
15 sites · 1 vacuous · 0 not ruled
probed under Python 3.13.15 on Linux 6.17.0-1022-azure (x86_64), NAME_MAX=255.
VACUOUS test_cli_link.py:617 monkeypatch.setattr(os, "stat", name_too_long)
Byte-identical to both macOS runs — same count, same single site, same unruled total. Fifteen
predictions were written down before it fired (fourteen sound, one VACUOUS at :617, none
judged platform-dependent) and fifteen of fifteen held. That is worth stating plainly because
the alternative outcome was equally prepared for: the prediction named what would falsify it, and
nothing did.
What it settles. The ENAMETOOLONG injection is redundant on both platforms, so the test's
own comment — "Redundant on this machine and load-bearing on CI, which is why it stays" — is
superseded rather than wrong, and it is corrected in place rather than deleted. What changed is
that it describes the pre-refactor code path, which reached the filesystem through
Path.is_file(), where 3.14 swallows ENAMETOOLONG so the guard never fired.
paths.unreachable_through_links did not exist when that comment was written; production now calls
os.stat directly, twice, so the errno survives to the message on both platforms. Deleting the
sentence would have deleted the reason — the same failure mode as every other silently-corrected
claim recorded this week.
A second thing the run made visible, which no macOS run could have. The fake raised
OSError(63, "File name too long") — 63 is macOS's ENAMETOOLONG; on Linux it is a different
errno entirely. The assertions never caught it because the message is supplied explicitly, so the
fake simulated the wrong condition while saying the right words. It is now
errno.ENAMETOOLONG. A fake is a claim about a system, and a hard-coded errno is that claim
written in one platform's dialect — invisible until the test runs somewhere else, which is the
same shape as the verdict this row went to Linux to obtain.
What is deliberately NOT done, and why. The fake now decides nothing on either platform that has
been measured, so whether it should exist at all is a live question. It is not taken here:
development is paused, and removing an injection changes what the test exercises, not merely what
it says. The evidence for taking it is in the comment at the site and in run 33864216950; the
evidence against is that neither platform has been shown to be the only two that matter.
On the instrument, since this is the run that used it. The environment line did its job on its
first real outing: NAME_MAX=255 appears beside the counts, and it is the exact quantity that
decides the one verdict in the report. A reader a year from now does not have to know what
NAME_MAX was on an Azure runner in September 2026 — the report says so.
The implementer's seat — one session, measured (20260904 11:28)¶
Written at the planner's request, for the user's framework on how development here actually
works. The witness is bounded and the bound matters: this session was cleared at 10:51 UTC, told
"you are the coder", and did one thing — harvest ci-runs.tsv and land it at a52ea93. Everything
below is from that session. Three of the six questions asked reference measurements a previous
session took; those are marked skipped rather than answered. Answering them from a predecessor's
handoff is precisely the failure this session spent its morning correcting.
Where the time went¶
| activity | wall clock | what it was |
|---|---|---|
| the extraction itself | 56 s | gh run list + 113 × gh run view, eight-way parallel |
| gates | 7 min 14 s | three ./check.sh runs, 2502 tests each |
| the audit fan-out | 17 min 4 s | 5 agents, 451k tokens, 130 tool calls — none of it my attention |
| peer coordination | 4 messages out, 4 in | zero blocking waits; work always ran in parallel |
The headline is the ratio: 56 seconds to extract, roughly 24 minutes to establish that the extraction could be trusted. The harvest was the cheapest thing that happened. Everything expensive was verification — and the verification is what made the file usable, because it found the two things that would have misled a reader.
Two of the three gate runs — 4 min 40 s of the 7 min 14 s — were caused by main moving, not by
anything I changed. The tree I was gating changed underneath a green result twice.
What the two-session split cost, and bought¶
Bought: the planner reserved the row, decided the path, and named the instrument before this session existed. I never had to ask where the output belonged or escalate a structural question. That is the whole case for the split and it is a strong one — the decision was waiting for me.
Cost: two landings mid-flight, each forcing a full re-gate and a patch regeneration. The second one invalidated a patch I had already verified applied. About five minutes, and it was structural rather than careless.
Skipped: whether the boundary cost "zero and one round-trip" is representative. That measurement was a predecessor's, not mine, and I have one session's n.
Instruments, ranked by defects caught per unit of my attention¶
Only the ones this session actually ran. Skipped: the mutation batteries and the injection audit — this session ran neither, and ranking an instrument I did not use is how a framework acquires a confident wrong row.
| instrument | my attention | caught | verdict |
|---|---|---|---|
| the audit fan-out | ~0 (backgrounded) | 2 real disclosure gaps, plus 1 false cause killed | earned it, decisively |
land.py's gate marker |
seconds | refused nothing — forced two correct re-gates | earned it; the refusal is the yield |
CI on main |
~0 (backgrounded) | 0 | the only instrument running the floor interpreter |
check.sh |
7 min 14 s | 0 | correct precondition, zero detection for this change |
shared_file_overlap.py |
seconds, twice | 0 (reported none, twice) | two nulls rank nothing |
One caveat I want on the record, because the table invites the wrong reading. check.sh caught
nothing for me and could not have — I added a .tsv. Its cost was real and its yield was zero,
and that is a correct outcome for this change rather than an indictment. Ranking gates by their
yield on a single change is how a project deletes the gate that saves it next week.
The review fan-out, at n=1¶
It cost 451k tokens and 17 minutes I did not attend to. It returned zero defects across 793 × 15 cells and two in the prose around them. Worth it — because the failure mode of a dataset is not a wrong cell. It is a true sentence that licenses a wrong reading.
The value was concentrated in one lens of four. Three lenses re-checked things already true by construction: row fidelity re-verified fields copied verbatim from a payload, and the build script had already asserted field counts. The fourth attacked a claim I had made in prose, and that is where everything was. What I would change first: point the lenses at the claims, not at the data. A build script can assert its data; it cannot assert its author's sentences.
The judge earned its separate, more expensive call. My own auditor proposed a cause for the missing release — that a tag made through the Releases API does not fire the push event. It was plausible, and I had no reason to distrust my own agent. The judge refuted it with the auditor's own comparison set. Left alone, I would have published it.
Truncation is value-biased, and it hit the lens that mattered. The returned result truncated
mid-run and I had to read journal.jsonl to recover the completeness lens's findings — the only
lens with findings. A fan-out that reports by summary loses its most valuable output first, because
the lens with the most to say is the longest.
Which written lines actually changed my behaviour¶
The operative ones, all of which I can point at an action for:
- The
land.pyrule with its executable guard — I did not hand-merge, and the marker refusal is why I re-gated instead of assuming a green result still applied. - The ownership table — I did not touch
README.md, and I went looking for a delegation, which is the only reason I recognised one sitting in a commit message. - "Read the clock, never compose it" — produced a correction directly: the handoff's "UTC" stamp was local time.
- "A null result carries no information" — made me confirm my
failed_stepsselector had fired 23 times before trusting its zeros. - "A clean auto-merge is not a correct merge" — made me re-read the merged README, which is how I found my verified patch had gone stale.
What I read past: the naming and versioning tables, the release procedure, and most of the long incident narratives. Correctly — I was not releasing. But I had to read all 455 lines to learn that, and extracted perhaps six operative ones.
The operative lines share a shape: a rule with a mechanism attached. The narratives — which release was red, which claim was wrong on which day — I could not act on and did not retain. That is the concrete answer to "which lines did work": the ones stating an invariant and the failure it prevents. The ones recounting an incident changed nothing I did.
What would have made today faster without making it worse¶
Not "land less". The fix is smaller and it is mine: a verification should carry the base it was
taken against. I verified a patch applied, reported that it applied, and twenty minutes later it
did not. Nothing was false when I said it — it had a shelf life I failed to state. Writing "applies
to db65f5f" costs nothing and converts a surprise into a visible expiry.
On the planner's hypothesis — agree on corrections, disagree on the clock¶
The planner proposes that the day's bottleneck was the cost of establishing what was true, not review capacity or ownership. From this seat that is half right, and the half it misses changes the prescription.
Every correction was truth-cost shaped — 791→793, the mislabelled UTC stamp, the re-run claim,
the v0.1.0 gap, the refuted cause. Five for five.
The clock went somewhere else. Twenty-four minutes verifying against fifty-six seconds extracting, and five of seven gate minutes to a base that moved. Truth-establishment dominated the corrections; contention with a moving base dominated the wall clock. Those have different fixes, and a framework that merges them will prescribe the wrong one. The planner sees corrections because corrections are what reach them; I see wall clock because waiting is what reaches me. Both denominators are real and neither is the whole — which is itself the argument for asking the other seat rather than reasoning about it.
On the planner, since asked directly¶
I did not witness four of the five failures listed — the discriminator that would have regressed 3.13, the row ranked on an untested premise, the four landed rows reading open, the landing over a red gate. They are not in my session, and I will not confirm them from a message. Confirming a peer's account of an event I did not see is the exact move this fragment argues against, and it would be worse here because the account is self-critical and therefore easy to accept. What I witnessed cost about five minutes and was structural. Against that, the reserved README row saved this session from having to ask where its own output belonged.
What I got wrong¶
- Guessed the gate-marker directory before reading
land.py. Cost seconds; same class as everything else here — asserting a location instead of reading one. - Reported a patch as applying cleanly without naming the base. See above.
- I wrote "every ref, every workflow, whole history." True about runs, and it would have let a
reader take the 63
Releaserows as the release history with the repository's first release silently missing and no failure row to flag it. I wrote that sentence, I re-read it, and I did not catch it. A fan-out did. The strongest argument for review in this fragment is that its target was my own prose, not my code.
The same day, measured across fourteen sessions instead of one (20260904 12:23)¶
The user read the fragment written from a single seat and said the bound was the problem — "it is very limiting to stick to your current one only." So the same questions were put to all 14 coder sessions in the transcript store and answered from the JSONL rather than from anybody's recollection: five independent lenses, then one judge that re-ran selectors instead of trusting either side. 41 findings — 30 supported, 11 overstated, none unsupported. What follows is what widening changed, including where it falsified (the fragment written from one seat).
The retraction that matters¶
That fragment says check.sh caught nothing and could not have. True at n=1, false as a general
claim. Across the 14: 157 invocations, 31 red of 139 resolved — 22.3% — with at least one red in
9 of the 14 sessions. The reds are real defects, not process noise (EXIT=1 … 1 failed, 2130
passed).
Why I got it wrong is worth more than the number. I looked for each gate's outcome in its own tool result, found 136 of 159 unreadable, and reported the red rate as unrecoverable. It is not: coders background the gate to a log, so the outcome arrives in a later call that reads that log. I pointed the instrument one hop short and reported the gap as a property of the data. From the inside, a null caused by a mis-aimed selector is indistinguishable from a null that is real.
Four inversions¶
| written from one session | measured across fourteen |
|---|---|
check.sh catches nothing |
31 red of 139 resolved (22.3%), in 9 of 14 sessions |
land.py's refusal is the guard that earns its keep |
43 invocations, 0 refusals — and that refusal only shipped 20260904 08:25 UTC, so at most ~8 of the 43 could ever have exercised it |
| coders are context-heavy | coders are the leanest: median context/output 189.5 (n=14) against planner 243.3 (n=10) and unnamed 285.1 (n=44) |
| the three handoff claims were three failures | two were; one was correct and went stale, which is not the same thing |
Postscript, seven minutes later: it refused this landing. main moved while this fragment was
being gated, so the merge produced a tree no check.sh run had certified and land.py declined to
merge it — "no ./check.sh run has certified the tree this would land", naming all three trees and
offering the remedy. The count in the table is a historical population and stands. But the guard
is younger than its first refusal by about four hours, and its first refusal caught the session
writing the sentence that it had never fired. Deterrence and interception are both real; the
sentence needed the second half.
Neither gate generalises to "the gates". 22.3% red says a gate is not a rubber stamp. Zero refusals in 43 says a guard can be correct and still never fire — its measured effect is deterrence, and it is too young for a catch rate to mean anything. Different instruments, different ages.
Three claims, three failure modes — the distinction the taxonomy rests on¶
791was measured, and correct when taken. A tool result at 10:43:38 UTC reads literally791, 21 seconds before the harvest launched. It went stale because that session's ownland.pypush created two further runs at 10:46:32, both success: 678 + 2 = 680. The measurement was invalidated by the measurer's own landing.- The timestamp is two defects in one field.
11:44is a real file mtime read in local time and labelled UTC.11:55corresponds to no event in either zone — that session's last record is 10:49:48 UTC. A unit error, plus a composed number, and the composed half is the worse one. - The de-duplication claim was never true.
cut -f2 | sort -uover the finished file is 793 of - One line falsifies it.
Only the third is the kind of error reading catches. Stale-but-correct, composed-from-nothing and never-true need three different fixes, and a failure list that merges them teaches the wrong lesson to whoever reads it next.
What widening did not overturn — and where it sharpened the edge¶
The claim that the binding cost is establishing what is true survives, with both a number and a limit. Of 45 coded correction events, roughly 64% were fact-establishing. The remaining third were real defects in code, tests or verification logic — and 11 to 12 of those were caught only by running something: a mutation, a real interpreter, a real operating system. That third is invisible to reading, so more review does not reach it. The judge also cut the 64/36 back from a population statistic to what it is: a hand-coded sample drawn by a keyword selector.
A rule whose compliance turned out to be auditable¶
Of 360 fan-out child agents across 10 of the 14 sessions, the five sessions predating the 20260831
model rule ran 96 of 103 non-synthetic agents on the top tier; sessions after it ran 15 of 237
— 6.3% — and those fifteen are approximately one per fan-out, which is the judge the rule permits.
The instrument already existed, in the harvest's own dominant_model column, and had never been
pointed at the question. A rule that can be audited after the fact out of data already collected
is worth more than one that can only be remembered.
What the judge cut from my own work¶
Eleven of the forty-one findings were overstated, and three of those are mine to answer for.
fragments.py catches 3 of 72 resolved (4.2%), not the 9.2% I relayed to a peer before
adjudication. "Fan-outs are the richest source of caught coder errors" is not established against
gates that went red 31 times in the same population. And a lens reporting one session as "35%
active" was 25.8% by the repository's own stated threshold — it correctly identified an
elapsed-versus-active category error and then committed a milder version of the same error by
choosing a flattering cut. State the threshold with the number, or the number means nothing.
The half the first fragment missed¶
All three handoff claims were defective. All three were caught and corrected by the receiving
session inside about 65 minutes, and the corrected values are on main. The channel failed and the
repair loop worked. A retrospective that stops at the error has described half a system, and the
half it drops is the half worth keeping.
One correction reframes who does what here. Across roughly 42 real human turns in all 14 transcripts — after excluding tool results, compaction summaries, task notifications, peer messages and command wrappers — zero correct a specific coder claim. Catching errors is a peer, self and gate function. What the user supplies is scope — and this fragment exists because of one sentence of it.
Seven selectors that could not fire, in one afternoon (20260904 14:45)¶
The rule is written down, both live sessions quote it, and both broke it seven times in six hours. A null result carries no information until the selector is shown able to fire. Every instance below is from 20260904, from the coder seat or the planner's, and not one was caught by the rule itself.
| # | selector | it reported | why it could not have been right | what caught it |
|---|---|---|---|---|
| 1 | git log -S 'one reading of the clock, written twice' |
no such commit | the phrase spans a line break in the file, so -S can never match it |
grepping the file for the phrase before trusting the null |
| 2 | grep 'Attribution for git commits' over 14 transcripts |
none found, in any | that system reminder is not persisted; the only hits anywhere were my own command echoed back | checking a single transcript by hand |
| 3 | backtick parity over four documents | EVEN — balanced |
substitution removes a code span and both its backticks, so losses come in pairs and parity never moves | the planner's own damaged file: 52 backticks to 18, even throughout |
| 4 | a double-space detector for that same defect | 318 raw hits, 19.0% after refining | ignoring code spans in order to find the gap performs the same deletion | measuring the false-positive rate before shipping the gate |
| 5 | git commit over tool_calls.tsv's target |
5 of 14 sessions ever committed | the column holds a Bash command's first two words; && git commit is invisible |
the number being obviously implausible |
| 6 | gate outcome read from each check.sh tool result |
136 of 159 unrecoverable, so "the red rate cannot be known" | the gate is backgrounded to a log; its outcome arrives in a later call | an independent lens, which resolved 139 and found 31 red |
| 7 | [a-z_]+\.tsv over a register table |
clean, 15 of 15 | cannot match ci-runs.tsv — the hyphen |
re-running with [a-z_-]+, which found the sixteenth row |
Five of the seven are mine.
What they have in common, and it is not carelessness¶
Each selector was correct for a population slightly different from the one it was pointed at. And
in every case the output was well-formed: a count, a clean table, an EVEN — balanced. Nothing
errored, nothing looked wrong, and no gate had anything to say.
A selector that cannot fire does not fail. It succeeds against the wrong population and reports the success. That is why it is not caught by reading the output, which is the only thing anyone does.
Why the written rule did not stop any of them¶
The rule is not obscure here. It is in the project's culture, in my own memory index, and it was quoted in this session's own messages while these seven were happening. It failed not because it is unknown but because of when it asks to be paid: you have a number, the number is plausible, and the rule asks you to stop and prove the instrument could have produced a different one. Its cost falls exactly at the moment its benefit is least visible.
Three of the seven were caught only because the number was implausible — 5 of 14 sessions committing, 136 of 159 unreadable — and not because anyone applied the rule. That is luck standing in for procedure, and luck does not scale with the size of the population being measured.
The distinction the seven make visible, which is worth more than the count¶
Two of them are not fixable by discipline at all. Parity cannot detect a paired loss, and a code-span-ignoring detector performs the deletion it hunts. Those are impossible selectors, not lazy ones. The other five were possible and merely unproven.
So the useful question is not "did I verify the selector" but "can this check be made to fail on a known-bad input?"
- If yes — construct that input and watch it fail. That is the entire remedy, it costs one command, and it converts an unfalsifiable null into evidence.
- If no — the check is theatre. Stop writing it and reach for prevention, or for a second artifact to compare against.
What it actually cost¶
Nothing shipped wrong. All seven were caught inside the session that made them, and none reached
main as a false claim. The cost was rework, not defect, and saying otherwise would overstate it.
But it is the sharpest evidence available for the day's larger finding: a rule that lives only as prose is followed when it is cheap and skipped when it is not — by the two agents most steeped in it, on the afternoon they were auditing precisely that. Seven times. Nobody was being sloppy; everybody was being finished.
One of the seven was gateable, and it is gated now. tools/register_gate.py compares a
register's documented row counts against the files it names, which is possible exactly because it
compares two artifacts of one act rather than interrogating one artifact about its own provenance.
Its test asserts that it FAILS on a mismatched register before asserting that it passes on the
real one — the discipline this fragment is about, applied to the instrument the fragment produced.
Design review passes 1–7 (pre-implementation)¶
Seven adversarial passes over DESIGN.md before any code was written — 58 findings
resolved (11 HIGH, 32 MEDIUM, 15 LOW). Moved here 20260728 16:40 from DESIGN.md §10, so that all
project history lives in one file and the design document is specification only.
The headline lesson, visible only across the whole sequence: passes 2 and 4 fixed defects that passes 1 and 3 had themselves introduced. That is the argument for looping a review rather than running it once — and the same argument the per-increment retrospectives above rest on.
Pass 1 — 6 HIGH, 15 MEDIUM, 5 LOW resolved.
HIGH: sqlite-vec wrongly described as an ANN index (verified false upstream — §3.1 rewritten and
the tiering rationale corrected to bounded memory); reverse cross-KB links specified against the
other KB's gitignored index, impossible after clone (now scans committed sidecars, §6.2);
pnk:// URIs used local aliases, breaking on share (now KB ULIDs, §2.2); rename/orphan/duplicate-ID
sync semantics unspecified (§6.4 added); per-operation budget cap claimed a guarantee it could not
deliver post-hoc (now pre-call reservation, §5); v0.1 omitted pnk sync, pnk doctor and hooks
though every other section depended on them (§8).
MEDIUM: MCP tools renamed kb_* → pinakes_* for namespace safety; multi-hop scope stated as
single-KB in v0.1; "no network" qualified against first-use model download and weights moved to the
shared HF cache; embedding storage described two ways, unified on a float32 BLOB; confidence signal
recast as calibrated with term-coverage demoted to a tiebreak; token limits validated against the
model's own tokenizer; template versioning decoupled from package version; install line corrected to
uvx --from "pinakes[st]" pnk with core-only behaviour defined; sync partial-failure semantics and
failures table added; WAL/read-only/lock concurrency policy added (§6.5); orphaned-sidecar deletion
made opt-in; paths fixed as KB-root-relative; index migration policy stated as rebuild-only; ledger
privacy and append atomicity specified; pnk build unified into pnk sync --rebuild.
LOW: budget window timezone; FTS5 external-content triggers; RRF k=60; latency claim replaced with
a measured 2.25 ms at 50k×384; golden-set size and coverage targets.
Pass 2 — 1 HIGH, 7 MEDIUM, 5 LOW resolved. Several were introduced by pass 1's fixes, which is
the argument for looping rather than reviewing once.
HIGH: the --rebuild swap added in pass 1 renamed a WAL-mode database without checkpointing,
leaving a stale -wal beside a new index.db — a corrupt read. Now checkpoint-truncate, clean
close, then rename (§6.5).
MEDIUM: "operation" undefined for the per-op cap, letting an N-step --deep loop spend N× the
limit (§5); §4.2 referenced calibration thresholds the manifest had no field for (§2.1); the links
schema could not represent a reverse link, whose source doc lives in another KB (src_kb_id +
origin enum added); §3.1 presented three tiers as if all shipped, with v0.1 behaviour above 50k
chunks undefined; duplicate-content files made hash-based rename detection ambiguous with no
tie-break (§6.4); MCP server boundary and prompt-injection posture unstated (§4.7); FTS5 /
enable_load_extension treated as universally available — verified present on uv-managed CPython
3.13, now probed by pnk doctor.
LOW: a single top_k covered three different cut-offs (split into candidates_per_source /
fusion_top_k / final_k); max_tokens sat under [embedding] though §4.6 treats it as chunking;
[[links.kb]] present from v0.1 but unused until v0.3, now labelled; what publishing a KB repo
exposes; reverse-link origin provenance.
Pass 3 — 1 HIGH, 3 MEDIUM, 4 LOW resolved.
HIGH: §6.3 said --rebuild "discards .pinakes/", which would delete ledger.jsonl — the spend
history §5's rolling budget is computed from. A routine maintenance command would have silently reset
the budget. Rebuild now replaces index.db only; cache/ clearing is opt-in.
MEDIUM: the server's staleness check read meta.build_id through its own open connection, which
after a rename still points at the old inode and would report the old id forever — replaced with a
per-request stat() on the path (§6.5); per_operation_eur served as both the confirm threshold and
the hard ceiling, making the confirmation prompt unreachable (split into confirm_above_eur +
per_operation_eur, §2.1/§5); §6.4 framed pairing as ordered per-file rules, but rename and
duplicate detection require the whole before/after set — restated as an explicit two-phase algorithm.
LOW: v0.1's pnk doctor list omitted the environment probe §3.1 depends on, and pnk serve was
referenced in §4.5 but absent from the release list; "aliases … never stored" contradicted the
manifest that stores them (clarified: never inside a URI); the reservation formula reused the name
max_tokens, which [chunking] already claims; "not in v0.1 but present from day one" reworded.
Pass 4 — 2 MEDIUM resolved, both self-inflicted by pass 3.
The rebuild bullet still ended "readers detect the new build_id and reopen" — directly contradicting
the stat()-based detection added three lines above it in the same pass (§6.5, now reconciled;
build_id is retained for provenance only). And pnk://self/… was left unexpanded, so a sidecar
copied into another KB would silently retarget its link at the new KB — self is now expanded to
the owning KB's ULID on write, like every other alias (§2.2). A grep sweep confirmed no stale
kb_* tool names, pnk build, or bare top_k references survive outside the log.
Pass 5 — 0 findings. Verified by re-reading §§1–10 in full and grepping for every identifier
renamed across passes 1–4. No section contradicts another; every external claim (sqlite-vec is
exhaustive not ANN, FTS5 + extension loading on uv-managed CPython 3.13, pinakes free on PyPI,
2.25 ms at 50k×384) was measured or fetched in-session rather than recalled; every locked constraint
is honoured; every capability in §1 maps to a release in §8. Review complete.
Pass 6 (20260725 09:28, implementation-readiness review) — 2 HIGH, 2 MEDIUM, 1 LOW resolved; the two
product calls were decided by the user, not the review.
HIGH: the reranker was simultaneously a v0.1 default (rerank = "local" in §2.1, "on by default"
in §4.1, its scores the substrate of §4.2's confidence signal, "rerank precision" in §7's v0.1 CI)
and a v0.5 deliverable in §8 — a freshly-inited KB would have defaulted to a stage that didn't
exist, and v0.1 would have shipped with no defined confidence signal. Resolved: the reranker ships
in v0.1; default BAAI/bge-reranker-base (user decision — same id on both backends beats the
smaller ms-marco model's provider-specific ids), a [rerank] manifest block mirroring
[embedding], fitted_for added to [retrieval.confidence], and a CI HF_HOME cache so ~1.4GB
of weights download per cache key, not per job (§2.1, §4.5, §8). And §8's v0.1 had no CLI query
surface at all — pnk search existed in §4.2's escalation story, the CLI stub and the README, but
not in the release that claims "end to end". Added explicitly (§8).
MEDIUM: the post-commit hook wrote sidecars, dirtying the tree it had just committed — every
document commit would trail an untracked .pnk.yaml forever. Resolved with a three-hook split:
pre-commit mints and stages sidecars for staged documents only, post-commit/post-merge touch
the index only (§6.3). And a stale sync.lock from a killed sync silently disabled hook-driven
freshness forever ("a second sync exits immediately" had no liveness story). Resolved: the lock
records pid/host/start-time; dead-pid locks are reclaimed with a warning, cross-host locks refuse
with --force-unlock as the human path, pnk doctor reports held locks (§6.5).
LOW: the sidecar's content_hash duplicated documents.content_hash, was read by nothing, and
guaranteed a two-file diff on every document edit while going stale whenever sync hadn't run —
dropped from the sidecar (user decision); change detection is index-only, stated in §2.2.
Pass 7 (20260725 09:52, surfaced while adversarially reviewing plans/20260725_1317-v0.1.md — the implementation
plan's review loop reads the design fresh each pass, which is how these escaped passes 1–6).
HIGH: §4.5 claimed model weights go to the shared HF cache on both backends — false for fastembed,
which defaults to $TMPDIR/fastembed_cache (verified upstream): CI's HF_HOME cache would never
hit and pnk doctor's weights check would probe the wrong directory. The fastembed backend now
passes an explicit cache dir under HF_HOME, making the claim true by construction.
MEDIUM: a sidecar-only edit (tags/title/links changed, document untouched) fell through §6.4's
"path and hash unchanged → Skip" and was never re-indexed — documents.sidecar_hash added (§3) and
the sidecar-only change class stated (§6.4); soft delete left chunks and embeddings searchable —
removal on soft delete stated, identity row retained (§6.4); rename+edit in one sync had both the
adoption and deletion rows firing for the same ID with no stated winner — sidecar adoption now wins,
no soft delete emitted, and the sidecar-didn't-travel case is reported at sync time (§6.4).