Skip to content

ADR-009 stages 9.1-9.4: migrations, backups, secret scrubbing, .graphlink archive - #303

Merged
dovvnloading merged 1 commit into
mainfrom
adr009a-backups-migrations
Aug 9, 2026
Merged

ADR-009 stages 9.1-9.4: migrations, backups, secret scrubbing, .graphlink archive#303
dovvnloading merged 1 commit into
mainfrom
adr009a-backups-migrations

Conversation

@dovvnloading

@dovvnloading dovvnloading commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Problem

Local storage had no schema-migration mechanism, no backups, and no
recovery path from a corrupt database. Two saves of the same chat could
silently clobber each other. There was no way to get data out of the app,
and no single place deciding what counts as a secret.

Change

9.1 — schema migrations. A shared run_sqlite_migrations driver keyed
on PRAGMA user_version, with ordered steps and a gap check. Both stores
move onto it: chats.db gets an explicit initial-schema step, and the
settings store's scattered backfills become an ordered chain. Migrations
run under a manual BEGIN/COMMIT/ROLLBACKsqlite3's legacy
transaction control implicitly commits before DDL, so with conn: is not
atomic here.

9.2 — backups, corruption recovery, concurrent-save safety. Backups
via SQLite's online backup API, pruned to the ten most recent plus one
per day. A corrupt database is quarantined (including its -wal/-shm
sidecars, which would otherwise replay onto the restored file) and
restored from the newest good backup. Saves are optimistically
concurrency-checked on updated_at and raise ConcurrentSaveConflict
rather than overwriting, using microsecond timestamps — second resolution
defeats the check.

9.3 — secret scrubbing. One chokepoint, backend/secret_scrub.py,
matching both on key name and on credential-shaped values, plus absolute
paths in all three forms (Windows, UNC, POSIX).

9.4 — the .graphlink archive. A plain zip: chats as JSON, assets as
real files, so it is readable without this app. Every payload is scrubbed
on the way out. Import validates before writing anything, refuses zip-slip
and absolute member names, caps member size against decompression bombs,
refuses newer format versions rather than guessing, and drops assets whose
bytes do not hash to their own ref.

What is not here

Stages 9.5 (asset externalization) and 9.6 (flat edge format) — the two
on-disk format changes. They ship separately.

Test plan

  • backend/tests/test_migrations.py, test_db_backup.py,
    test_chat_library.py — migration ordering and gap detection,
    transaction rollback, backup/prune/restore, quarantine including
    sidecars, concurrent-save conflict.
  • backend/tests/test_secret_scrub.py (12) — written as attempts to sneak
    a secret past the scrubber, not as a happy-path walk.
  • backend/tests/test_workspace_archive.py (16) — round-trip into a
    different asset store (the second-machine case), zero secrets in the
    archive, and the hostile-input set.
  • Full suite from repo root: 2142 passed, 16 skipped.

…covery

chats.db had no way to evolve and no way to recover. Schema was re-probed
with CREATE TABLE IF NOT EXISTS / conditional ALTER on every single
connection, with no PRAGMA user_version anywhere - so there was no
supported path to change the on-disk shape in a release. And autosave
overwrites the one and only copy every 30 seconds with no backup, so a
corrupt write loses the user's entire chat history with nothing to restore
from.

9.1 - graphlink_migrations.py adds an ordered migration runner for both
SQLite and the plain-dict session.dat state. The SQLite runner applies
steps in order inside one real transaction and bumps user_version only
after every step succeeds. This needs manual BEGIN/COMMIT with
isolation_level=None: Python's sqlite3 default implicitly commits before
DDL and PRAGMA, so `with conn:` would leave a failed chain half-applied.
chats.db's per-connection schema probing becomes one versioned migration
that also adds the missing FK indexes on notes.chat_id/pins.chat_id, plus
a busy_timeout. session.dat's scattered `if field not in state` backfills
become an explicit chain, behavior-preserving.

9.2 - backend/db_backup.py snapshots chats.db via SQLite's online backup
API rather than a file copy, since the live DB can be mid-write and a raw
copy of a WAL database can be torn. Retention keeps the 10 most recent
plus one per calendar day. On corruption the live file is quarantined to
.corrupted-<ts> and the newest backup is restored in its place; if no
backup exists the user is told so rather than being silently handed an
empty library. Saves become UPDATE ... WHERE id = ? AND updated_at = ?, so
a lost race raises instead of clobbering the other writer.

Timestamps carry microseconds. At second resolution two saves inside the
same wall-clock second share a token, which makes a stale value
indistinguishable from a fresh one and silently defeats the concurrency
check entirely.

Restore deletes the corrupt file's -wal/-shm sidecars. They describe
writes against the quarantined file's page layout; left in place, the next
connection would try to replay them onto the restored database.

Known limitation: a save against a chat another writer has deleted also
reports "modified elsewhere", since both cases surface as rowcount 0. It
refuses the write either way, so nothing is clobbered - only the wording
is imprecise.

Test plan: full pytest from repo root - 2114 passed, 16 skipped. The new
tests pass with #302's real-data guard active, confirming they are
tmp_path-isolated. Covers migration ordering and rollback-on-failure,
upgrading a pre-existing v0 database with real rows in it, retention
math, kill-9-mid-save recovery from a truncated database, and a two-
session lost-write race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit f250875 into main Aug 9, 2026
3 checks passed
@dovvnloading
dovvnloading deleted the adr009a-backups-migrations branch August 9, 2026 14:40
@dovvnloading dovvnloading changed the title ADR-009 stages 9.1-9.2: schema migrations, backups, and corrupt-DB recovery ADR-009: persistence — migrations, backups, recovery, secret scrubbing, and the .graphlink archive Aug 9, 2026
@dovvnloading dovvnloading changed the title ADR-009: persistence — migrations, backups, recovery, secret scrubbing, and the .graphlink archive ADR-009: durable local storage — migrations, backups, secret scrubbing, archive, asset store, flat edge format Aug 9, 2026
@dovvnloading dovvnloading changed the title ADR-009: durable local storage — migrations, backups, secret scrubbing, archive, asset store, flat edge format ADR-009 stages 9.1-9.4: migrations, backups, secret scrubbing, .graphlink archive Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant