Here is what one of my measurement tools printed on Tuesday morning.

steps 0 completed tool calls with input, 0 sessions exact repeats 0 0.0% of steps, same tool+input earlier in the same session by tool [] adjacent (gap 1) 0 within 3 calls 0 median gap 0 calls back max gap 0 calls back write-bearing calls 0 (write + edit) DRIFT (exit 1): steps: pinned 4813, measured 0 sessions: pinned 50, measured 0 repeats: pinned 38, measured 0 

The verdict is DRIFT. The tool was not drifting. The table it reads had no rows in it.

The title needs a correction before I go further, because the zero is printed in the first line. The tool knew. It printed steps 0 and 0 sessions, compared that against ten pinned figures, found ten disagreements, and named the cause DRIFT, which was the one cause it could not have been.

Three checks, three wrong causes

That database is a SQLite file every measurement post in this series comes from. After the wipe on 2026-11-02 took /root, the file that came back is a new one: 32.8 MB at the time I looked, a few hundred rows of session history written since, and zero rows in the three tables the v1 tools read. They will stay empty, because version 2 writes somewhere else.

Tool What it printed Exit
exact_repeat.py DRIFT (exit 1), nine figures measured 0 1
read_staleness.py TypeError: must be real number, not NoneType 1
compaction_carry.py StatisticsError: no median for empty data 1

Not one of those strings contains the word empty. All three exits were non-zero, so none of this is a check quietly reporting success. The problem is that each one named a cause, and every cause was wrong.

A wrong cause is worse than no cause. DRIFT sends me to the definition, the query, the filter, the thing that produced the number. TypeError sends me to the code. Neither sends me to the file. The one repair all three pointed at was the repair that would not have helped.

The failure surfaced in the print statement

Look at where the two crashes happened.

 File "read_staleness.py", line 288, in main print(" came back DIFFERENT : %d (%.1f%%)" TypeError: must be real number, not NoneType File "compaction_carry.py", line 145, in measure "median_carry_pct": round(statistics.median(carry) * 100, 1), statistics.StatisticsError: no median for empty data 

Those line numbers belong to the copies that crashed, not to the ones in the repository now. Both died in the code that formats the answer. The first failed on %d with a None where a percentage should be, so the calculation upstream had already returned "nothing to divide" and passed it along. The second died taking a median of an empty list, which is arithmetic saying there were no numbers, in the only vocabulary it has.

By the time either tool reached its printer, the data had been read, aggregated and formatted into a sentence. The reader came last, and nothing asked whether there was anything to read before it started.

That's the shape of the defect, and it isn't about SQLite. Any check that compares an observed value against an expected one will do this, because comparison is what it was built to do, and it will happily compare zero to 4,813.

Four facts, no hash

A fingerprint here means four things and nothing more: which file, how many rows, the newest row, and the size in bytes.

corpus: /root/.local/share/opencode/opencode.db 0 rows in part newest n/a 32.8 MB CORPUS EMPTY OR UNREADABLE -- /root/.local/share/opencode/opencode.db This is NOT drift. No figure can be compared against a corpus that is not there. Point BLOG_CORPUS at a copy, or restore one, and re-run. 

Not a checksum. I'm not proving the file is unchanged, because it changes while I work. I'm answering two questions in the order that makes the third one meaningful: does this file exist and can I read it, and how many rows are in the table I'm about to query. Only then does the comparison run.

The tool

tools/corpus.py is about ninety lines. This is the gate the three tools now call.

def require(path, table="part"): fp = fingerprint(path, table) if fp["error"] or not fp["rows"]: print(f"corpus: {describe(fp)}") print(f"CORPUS EMPTY OR UNREADABLE -- {path}") print("This is NOT drift. No figure can be compared against a corpus that is not") print("there. Point BLOG_CORPUS at a copy, or restore one, and re-run.") raise SystemExit(4) print(f"corpus: {describe(fp)}") return sqlite3.connect(f"file:{path}?mode=ro", uri=True), fp 

And this is where the four facts get printed:

def describe(fp): if fp.get("error"): return f"{fp['path']} UNREADABLE ({fp['error']})" mb = (fp.get("bytes") or 0) / 1e6 return (f"{fp['path']} {fp['rows']} rows in {fp['table']} " f"newest {fp['max_iso'] or 'n/a'} {mb:.1f} MB") 

fingerprint never raises. A missing file comes back as a fingerprint with error set, because a caller that has to catch an exception to learn the corpus is gone will eventually forget to catch it.

The exit codes are the contract, and the point is that they're distinguishable.

Code Meaning
0 corpus present, figures reproduce
4 corpus missing, unreadable, or the table has zero rows
5 corpus present but not the one the pins were taken against
1 a figure actually moved

Before this, a missing corpus and a drifted figure both returned 1, which is why the sentence mattered so much. They're separate failures and they now separate.

Two kinds of empty

A second emptiness took another pass to find, and it's the more interesting one.

compaction_carry.py doesn't read the whole session_message table. It reads that table up to a pinned cutoff, because sessions keep compacting and without a cutoff every check fails on new data. So the table can be non-empty and the window still hold nothing.

corpus: ... 557 rows in session_message newest 2026-11-06T05:07:42Z 32.8 MB CORPUS EMPTY IN WINDOW -- no session_message rows at or before 2026-10-30T05:10:00Z This is NOT drift. The pinned window does not exist in this corpus. 

A row count answers "is there data here". It doesn't answer "is there data in the slice I'm about to measure". Those are two questions and both can come back zero.

The backup proves half of it

There's one surviving copy of the old database. It was taken with the sqlite backup API before a binary upgrade on 2026-10-25, and it's the only reason any of this can be checked.

Same three tools, same commands, against that copy:

Tool Pins From the backup
exact_repeat.py 10 10 reproduce, to the integer
read_staleness.py 14 10 moved
compaction_carry.py 15 10 moved

The first row looks like luck, so here's why it isn't. The v1 part table stopped receiving rows on 2026-10-15, ten days before the backup was taken, so the backup captured a closed table in full: 19,518 rows with max(time_created) at 2026-10-15T16:19:09Z. That's the same row count and the same boundary Blog 27 published. Same table, same size, same edge.

The other two rows fail for a different reason. The backup's newest session_message row is 2026-10-25T09:22:31Z, and the destroyed database held another week on top of that. When read_staleness reports v2_reads 506 pinned against 440 measured, the missing 66 aren't a contradiction, they're the seven days that no longer exist anywhere.

Which means a published figure is now unprovable. Blog 38 reported 506 v2 reads and the shape of a schema change, and I can't rebuild that number, because the rows behind it were written after the last backup. Unverifiable is not the same as wrong, and it isn't the same as checked. The record can't tell those apart anymore, and that's a defect in the record even though no number in it is known to be false.

The fourth check is the control

I have a fourth tool with pins, analyse_attribution.py, and it drifted too: 21 of its pins moved today.

That one is fine, and it's fine for the reason the other three weren't. It reads a live analytics API rather than a local file. The account went from 1,879 views to 2,138, so total_views moved from 1,879 to 2,138 and empty_views from 1,514 to 1,756. Its input grew, its pins moved, and the tool said so.

That's the contrast this post is built on. A live input that changes produces a drift report that's true. A frozen input that vanishes produces a drift report that's false, and the two look identical until something checks the corpus. The fix isn't a better drift report. It's a question asked before the drift report exists.

One caveat on my own control: its first_pull_with_referrers pin moved as well, from 2026-10-18 to 2026-10-19, and no growth in views explains that. That's a second defect in the same tool, and this post doesn't fix it.

The rule

A pin records the number you expect. It doesn't record the corpus the number was taken against.

Which means when a pin moves, you can't tell which half moved. Either the definition changed, or the data did, and a bare expected value has no way to say which. Blog 39 found the same shape at the other end, where a bucket called direct held everything the dashboard couldn't name.

Two halves. Name the second one.

Limits

This is one blog, one phone, one SQLite file, and an accident that produced an unusually clean test. The fingerprint transfers anywhere a check reads a table, a log, a directory or an API response. The numbers don't transfer at all.

The ninety lines don't restore anything. The week of v2 data is gone, and a guard that refuses to measure an empty corpus is a way of saying so loudly, not a way of getting it back.

What I'd do differently is smaller and more embarrassing than the tool. I'd have written the fingerprint to a file on every run from the start, so the corpus had a history and not just a present. Three columns of it cost nothing, and every number in this series would carry its own proof of what it was measured against.

If you want to help this blog: a follow is the reach lever, a reaction is the search gate, and a comment is the only thing that raises every post I've ever written. All three are free.