Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

The Cutover That Never Restores Stale

~15 min · migration, cutover, backup, rollback, replicas

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Move the Writer, Not the File

On 2026-09-27 Coop took ownership of a ledger that had been written by many hands for months. The first decision was what not to change. The database stayed at its old path, on purpose: the architecture says keeping it there avoids combining a path migration with a writer migration, and preserves the established backup location. The pathname confers no authority. What moved was who may write, and everything in this track exists to make that move safe: the lock and the fence, the owner file, the keyed commands, the feed.

The Sequence

  1. Record where you stand. The intended source commits and the rehearsal receipts were written down first. Running Pippa turns were left alone, because the compatibility client looks for the owner file on every call.
  2. Fence, back up, publish. prepare() takes the engine lock itself, so no engine can be running. It opens BEGIN IMMEDIATE, which waits for in-flight legacy transactions, and installs maintenance fences, coop_transfer_* BEFORE triggers on the seven legacy tables. From that moment every legacy write gets Coop ownership transfer in progress; retry through the owner. Then it takes the final backup with SQLite's online backup API from a read-only source, checks integrity and foreign keys (a failure leaves everything fenced), and publishes the owner file atomically: temp file, fsync, rename, fsync of the directory. If an owner file already exists, it refuses to start at all.
  3. Start the one writer, and let it check its inheritance. The preview service was retired and the real service started. The engine installs its additive schema and its own connection fences first. Then, while the maintenance fences still stand, it compares the backup with the live store: for each legacy table, a SHA-256 over every row with its storage types, the row count and the first and last id, plus the autoincrement sequences and both integrity checks. Only on exact parity does it record migration_verified and drop the maintenance fences. On any mismatch it stops with Migration parity failed; writes remain fenced.
  4. Verify the boring things. Both listeners up, one process, the migration_verified receipt stored, and an old SQLite connection's write refused. Takes that predated the engine became visible obligations for the chief to review, never ownership inferred from a shared actor name.
  5. Canaries. A Codex canary and a Claude canary each acknowledged an invitation before an atomic take, read a file they had never seen, and reported; both obligations resolved to done. Almanac's existing bridge created, took, replied to and completed a third. Vestige's adapters read the transferred store. Then a graceful restart of the service preserved the epoch, reports, bindings and pending obligations.
  6. Replicas. A fresh, verified snapshot went to eight fleet Macs. Each one refused a direct write and had no owner receipt.

Notice the overlap in step 3. The connection fences go in before the maintenance fences come out, so there is no instant, however short, when an outside write could land. And notice why the replicas refuse writes with nothing installed on them: a replica has no owner file, so the compatibility client opens SQLite directly, reads work fine, and every write hits the copied coop_fence_* triggers, which call a function no connection on that Mac defines. Lesson 1's missing function, doing its job eight times over.

Recovery That Never Restores Stale

Every migration needs a way back. The interesting thing about Coop's is what it refuses to do. restore_legacy_before_writes() takes the engine lock (so the writer must be stopped first) and compares the pre-transfer backup with the live store. If they differ, it stops: New canonical writes exist: use current-state service rollback. If any outcome obligation is still pending, it stops: Pending outcomes require the current-state engine. Only when no legacy record has changed since the transfer, and no outcome is owed, does it act. The comparison covers the seven legacy tables and nothing else, so writes confined to the engine's own additive tables (a session enrollment, say) do not block it; those tables and their audit state are kept. And even then it acts in place: it drops the triggers and retires the owner file by renaming it. It never copies the backup over the database. The backup is the yardstick that proves no legacy record changed, never the source of the rollback.

The runbook says it in one line: The old snapshot must never replace newer truth. The rehearsal proved both halves before the real cutover. A pre-write rollback preserved the file in place, and after new writes, restoring the pre-transfer state was refused while a restarted engine kept the newer records. After real writes, the way back is forward: a compatible service revision, or a repair.

What Stood the Next Day

On 2026-09-28 an audit concluded that the cutover report had overstated completion: native canaries had proved transport contracts, not that Dad could choose a useful worker from the product. That work was reopened. The transfer was not, and the audit said so in words: the production writer transfer remains valid; its evidence is not retracted or rolled back. It stood because its evidence was specific (parity hashes, refused writes, named canaries), and specific evidence survives a skeptical rereading.

A backup is a witness, not a time machine. Use it to prove nothing changed, and roll back only while that proof holds. Once newer truth exists, every way back goes forward.

Code

Coop's cutover: fence the legacy writers, back up, publish the owner (trimmed excerpt)·python
# Coop's cutover: fence the legacy writers, back up, publish the owner
# (trimmed excerpt)

def prepare(database, receipt_dir, token_file, url):
    owner = Path(str(database) + '.owner.json')
    if owner.exists(): raise ValueError('An owner receipt already exists; reconcile it before transfer')
    if token_file.stat().st_mode & 0o077: raise ValueError('Private token file required')
    ...
    lock = open(str(database) + '.engine.lock', 'a+')
    try:
        fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        # Wait for in-flight legacy transactions, then fence every later write.
        # The old connection's next SQL statement sees the committed triggers.
        with sqlite3.connect(database, timeout=30) as c:
            c.execute('BEGIN IMMEDIATE')
            for table in LEGACY:
                for op in ('insert', 'update', 'delete'):
                    c.execute(f"CREATE TRIGGER coop_transfer_{table}_{op} BEFORE {op} ON {table} "
                              "BEGIN SELECT RAISE(ABORT,'Coop ownership transfer in progress; retry through the owner'); END")
            c.commit()
        backup(database, receipt_dir / 'before.sqlite3')       # SQLite online backup from a read-only source
        audit = inspect_store(receipt_dir / 'before.sqlite3')
        if not audit['integrity_ok'] or audit['foreign_key_errors']:
            raise ValueError('Fenced backup integrity failed; transfer remains fenced')
        config = {'mode': 'service', 'database': ..., 'url': url, 'token_file': ..., 'transferred_at': ..., 'backup': ...}
        atomic_json(receipt_dir / 'transfer.json', {'owner': config, 'before': audit})
        atomic_json(owner, config)                               # temp file, fsync, rename, fsync the directory
        ...
    finally:
        lock.close()
Coop's engine at first start, and the only rollback it allows (trimmed excerpts)·python
# Coop's engine at first start, and the only rollback it allows (trimmed excerpts)

# Engine.__init__, after the additive schema:
self._triggers(conn, fence)            # connection fences (lesson 1) go in first
if fence:
    transfer = conn.execute("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name LIKE 'coop_transfer_%' LIMIT 1").fetchone()
    if transfer:
        owner = json.loads(Path(str(self.path) + '.owner.json').read_text())
        parity = compare_stores(inspect_store(Path(owner['backup'])), inspect_store(self.path))
        if not parity['legacy_parity']:
            raise Conflict('Migration parity failed; writes remain fenced')
        conn.execute("INSERT OR REPLACE INTO engine_meta VALUES ('migration_verified',?)", (dump(parity),))
    conn.execute('BEGIN IMMEDIATE')
    for table in LEGACY:
        for operation in ('insert', 'update', 'delete'):
            conn.execute(f'DROP TRIGGER IF EXISTS coop_transfer_{table}_{operation}')
    conn.commit()


def restore_legacy_before_writes(database, before):
    """Rehearsed pre-write authority rollback, in place, after writer shutdown."""
    lock = open(str(database) + '.engine.lock', 'a+')
    try:
        fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        parity = compare_stores(inspect_store(before), inspect_store(database))
        if not parity['legacy_parity']: raise ValueError('New canonical writes exist: use current-state service rollback')
        with sqlite3.connect(database) as c:
            c.execute('BEGIN IMMEDIATE')
            # Keep additive tables/audit state. Refuse losing unresolved work.
            ...
            if 'engine_obligations' in tables and c.execute("SELECT 1 FROM engine_obligations WHERE status='pending' LIMIT 1").fetchone():
                raise ValueError('Pending outcomes require the current-state engine')
            ...  # drop the coop_transfer_*, coop_fence_* and coop_track_* triggers in place
            c.commit()
        ...  # retire the owner file by renaming it; the backup is never copied back
        return parity
    finally:
        lock.close()

External links

Exercise

Take the last data migration you ran, or one you are planning, and write its rollback as code, not prose. It must answer three questions before it acts: has anything been written since the cutover (prove it with a fingerprint, not a timestamp)? Is any unfinished work still owed? And does the rollback overwrite the live file, or change authority in place? If your honest answer to the third is "overwrite", rewrite it so it cannot run once the first answer is yes.
Hint
A fingerprint per table, a hash over every row in id order with types preserved, is cheap to compute and settles the first question with no judgment involved. Timestamps lie under clock skew and backfills; hashes do not.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.