fix: preserve updates after read-only SQLite probes

1 hour ago

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.

Ask about this presentation

Chapters

  1. 0:00A read-only probe no longer blocks an update
  2. 0:22Doctor refused unchanged data
  3. 0:41A read can change the WAL index
  4. 1:05One extra fingerprint for an empty reader
  5. 1:32The migration gate accepts the alternate
  6. 1:59What still refuses migration
  7. 2:24Six new test cases
  8. 2:47The published updater now completes
  9. 3:20Ship it in the next candidate
Show transcript

A read-only probe no longer blocks an update

OpenClaw

A read-only SQLite probe no longer blocks an update

Candidate Doctor now accepts an empty WAL index appearing or disappearing, and nothing else.

fix: preserve updates after read-only SQLite probes

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

OpenClaw

The update stopped in post-activation Doctor

Error: SQLite migration source changed after update capture.
The captured database contents were unchanged.
src/infra/update-doctor-migration-attribution.ts

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

OpenClaw

A read can create or remove the WAL index

Read-only open
A cold WAL database gets a shared-memory index with zero writes.
source.sqlite-shm appears
Last writer closes
SQLite removes that zero-write index again.
source.sqlite-shm disappears
Fingerprint differs
It hashes the index header's change counter and last commit checksum.
null → "00000000"
src/infra/update-database-generations.ts

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

OpenClaw

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) }
    : {}),
};
src/infra/update-database-generations.ts · readDatabaseGeneration()

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

OpenClaw

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);
}
src/infra/update-doctor-migration-attribution.ts · runUpdateSqliteMigration()

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

OpenClaw

Real writes are still refused

REFUSED
An outside commit
Changed committed data blocks migration and automatic restoration.
REFUSED
A write, then the old value back
TRUNCATE checkpoints keep the change counter and checksum.
STRICT CHECK KEPT
Exclusive handles and native close
Postimages and close attribution compare exactly, as before.
docs/install/updating.md

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

OpenClaw

The regressions fail on main and pass here

Read-only reader opensMIGRATES
Last writable reader closesMIGRATES
Already exclusiveCUSTODY KEPT
Retain exclusiveCUSTODY KEPT
Outside commit, different contentREJECTED
Outside commit, same contentREJECTED
src/infra/update-doctor-migration-attribution.test.ts · +104 lines

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

OpenClaw

The real 2026.10.6 updater now completes

exit 0
Durable run status succeeded, against the packed candidate in an isolated npm prefix
25 → 32
Schema for all 3 agents; shared state 20 → 31; memory and cache table hashes identical
12 + 21 tests
Migration and generation suite tests passing; 1,143 PostgreSQL CI tests passing
Evidence from the pull request body

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

OpenClaw
The next candidate must contain this repair for updates from 2026.10.6. The shipped updater keeps its fingerprint and handoff protocol.
The CI run has 3 UI test failures that reproduce on the unchanged base, a69cf566.
A read-only probe no longer blocks an update.
40 net production lines · no new protocol, configuration or schema

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.

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built in New York with ❤️