A read-only probe no longer blocks an update
A read-only SQLite probe no longer blocks an update
Candidate Doctor now accepts an empty WAL index appearing or disappearing, and nothing else.
Updates from the published 2026.10.6 release could stop in Doctor right after activation, and refuse to migrate a SQLite database whose contents had not changed. This change teaches the candidate to recognize the one harmless difference behind that, a read-only probe, and keeps every other check exactly as strict as before.
Doctor refused unchanged data
The update stopped in post-activation Doctor
The installed updater captures a fingerprint of each database during the update. After activation, the candidate's Doctor reads it again before migrating, and here it stopped with this error. The captured contents were identical. Between the capture and the check, something had only read the database.
A read can change the WAL index
A read can create or remove the WAL index
The cause is the WAL index. Opening a cold WAL database, even read-only, can create a shared-memory index with zero writes in it. Closing the last writable reader can remove it again. The fingerprint includes two fields from that index header, the change counter and the last commit checksum. So the index appearing or vanishing changes the hash, while the data stays exactly where it was.
One extra fingerprint for an empty reader
An empty reader gets one alternate
The shipped fingerprint stays byte-for-byte the same.
const fingerprint = (writeChange, writeChecksum) =>
sha256Hex(JSON.stringify(
[...identity, writeChange, writeChecksum]));
const emptyReader =
!generation.journal &&
(generation.wal?.size ?? 0n) === 0n &&
(after === null ||
(change === "00000000" &&
checksum === "0000000000000000"));
return {
generation: fingerprint(change, checksum),
...(emptyReader
? { emptyReaderAlternate: after === null
? fingerprint("00000000", "0000000000000000")
: fingerprint(null, null) }
: {}),
};
The per-file loop in update database generations moves into one function, read database generation, and the fingerprint it publishes is unchanged. It hashes the file identity and committed content, plus those two write-evidence fields. A database with no journal, an empty WAL, and either no index or an all-zero one counts as an empty reader. Only in that case does it also compute the fingerprint the other index state would have produced.
The migration gate accepts the alternate
Normal-mode handles check both fingerprints
Exclusive handles keep the isolated read.
const before =
expected === null ? null
: mode === "exclusive"
? readSqliteGeneration(pathname)
: readMatchingUpdateDatabaseGeneration(
pathname, expected);
assertCurrent();
if (before === undefined ||
(mode === "exclusive" && before !== expected)) {
throw new Error(
"SQLite migration source changed after update capture.");
}
if (before !== expected) {
capture.accept(pathname, expected, before);
}
The alternate is used in one place, the check that runs before native exclusion. A handle in normal locking mode reads through the new matcher, which returns the observed generation when either fingerprint matches. Exclusive handles keep the old isolated read, because closing a raw descriptor there would release their process locks. Every other mismatch still throws the same error. And when only the alternate matched, the capture records what it accepted.
What still refuses migration
Real writes are still refused
The exception is narrow on purpose. A commit from outside the capture still refuses migration and automatic restoration. So does a write that later puts the original value back, because a TRUNCATE checkpoint keeps the change counter and the last commit checksum. Exclusive handles, postimages and native close keep their exact comparisons. The updating guide now documents the exception and its limits.
Six new test cases
The regressions fail on main and pass here
| Read-only reader opens | MIGRATES |
| Last writable reader closes | MIGRATES |
| Already exclusive | CUSTODY KEPT |
| Retain exclusive | CUSTODY KEPT |
| Outside commit, different content | REJECTED |
| Outside commit, same content | REJECTED |
The migration attribution test gains six cases on real SQLite files. The two regressions, a read-only open and a last writable close, fail on unchanged main and migrate with the fix. Two more check that a migration in exclusive mode keeps native custody. And two outside commits, one that changes the value and one that writes it back, are still rejected.
The published updater now completes
The real 2026.10.6 updater now completes
The end-to-end run replays the failure with the real published updater. It now completes against the packed candidate in an isolated npm prefix, exit zero, with a durable run status of succeeded. All three agents migrate from schema 25 to 32, shared state moves from 20 to 31, and the memory and cache table hashes stay identical. The migration suite passes its 12 tests, the generation suite its 21, and the PostgreSQL shards pass over 1,100 tests.
Ship it in the next candidate
The repair lives on the candidate side, so an older installed updater benefits without a new handoff protocol. That also means the next candidate has to contain it for updates from 2026.10.6 to get through. The CI run carries three UI test failures, and each one reproduces on the unchanged base. It adds about 40 production lines and no protocol, configuration or schema. A read-only probe no longer blocks an update.









