Skip to content

docs(operations): document the sanctioned binary-downgrade recovery - #8148

Merged
houko merged 8 commits into
librefang:mainfrom
DaBlitzStein:docs/rollback-recovery
Sep 10, 2026
Merged

houko merged 8 commits into
librefang:mainfrom
DaBlitzStein:docs/rollback-recovery

Conversation

@DaBlitzStein

@DaBlitzStein DaBlitzStein commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Review round (@houko, 5 threads — all confirmed, all fixed in fefd5d28a)

  • data_dir / [memory] sqlite_path: the procedure assumed the databases live under <home_dir>/data/. boot.rs:378-383 resolves the memory DB as sqlite_path or else data_dir/librefang.db, while backup_source (backup.rs:152) hard-codes home_dir.join("data") — so on such a deployment the archive carries no database and every step is a no-op. Added a "Check first" precondition section, referenced from step 3 and from the "start fresh" bullet.
  • Quoted error text: was the guard's format string, not the journal line. LibreFangError::memory renders as Memory error: {message} (error.rs:61) and boot.rs:397 wraps it again as Memory init failed: {e}. The quote now carries both prefixes, and the page tells the operator to grep Downgrade is not supported rather than the head of the line.
  • Stale -wal: the bullet handled only -shm. unzip -o cannot overwrite an entry the archive does not carry, so a backup taken with the daemon stopped leaves the newer binary's WAL next to the restored database; SQLite replays those frames, page 1 and user_version included. Bullet widened to delete both sidecars before extracting.
  • "Configuration … survive": false for data/cron_jobs.json, data/hand_state.json and data/custom_models.json, which BACKUP_LAYOUT names separately but which sit inside the data/ tree the option moves aside. The page now says to copy those three back out.
  • Changelog fragment drift: it advertised "the backup/restore feature the daemon already ships", which reads as POST /api/restore — the path the page argues against. Reworded to name the offline restore, and this PR body's third bullet (which had the same drift) is corrected above.

Verification

  • Doc-only change: no code paths touched.
  • Every mechanism described was verified against source at fefd5d28a, not from memory: the backup routes (backup.rs:25-29), BACKUP_LAYOUT (backup.rs:68-80), backup_source (backup.rs:152), the SQLite databases' resolution (boot.rs:378-383, boot.rs:1811), the error wrapping (substrate.rs:217, error.rs:61, boot.rs:397), the data_dir / sqlite_path fields (types.rs:3470, types.rs:7401), and the -shm filtering on both backup and restore (backup.rs:185-192, backup.rs:314, backup.rs:949).
  • python3 scripts/check-changelog-attribution.py → OK: no changelog problems in scope.
  • No red/green test cycle is reported here because the change is prose only; there is no production branch to revert and no assertion that could discriminate.

Out-of-scope follow-ups

Issue librefang#8066 asked for forward-compatible migration stubs so an older binary could boot a database a newer binary had already migrated.
The stub approach was closed (librefang#8068): a stub claims a ladder version, run_step! skips the real migration behind that number, and the schema diverges silently.
The fail-loud guard that fires instead is the fix that already landed upstream (librefang#3962, closing librefang#3656, which documented the pre-guard silent corruption).
What was missing was the operator-facing half: what the refusal means and what the supported way back is.
Adds docs/operations/downgrade-recovery.md: the guard's verbatim error, why it refuses by design, what the backup feature covers (BACKUP_LAYOUT components including the SQLite databases under data/, minus the -shm index sidecar), the rollback procedure (restore the pre-upgrade archive, then restart on the old binary), the manual unzip fallback for a down daemon, and the honest no-backup options.
Carries a changelog fragment under changelog.d/documentation/.
@github-actions github-actions Bot added the area/docs Documentation and guides label Sep 2, 2026
@github-actions github-actions Bot added no-rust-required This task does not require Rust knowledge size/M 50-249 lines changed labels Sep 2, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked every code citation in the document against main and they all hold: SCHEMA_VERSION = 54 at migration.rs:8, the refusal at migration.rs:16-28 (the quoted error string is byte-for-byte the one the code formats), run_step!'s current_version < $version gate at migration.rs:95-104, run_migrations propagating out of the substrate constructor at substrate.rs:217, BACKUP_LAYOUT at backup.rs:68-80, the archive path construction at backup.rs:243-249, the -shm exclusions at backup.rs:314 / backup.rs:949, the restore contract at backup.rs:1023-1041, data/librefang.db at boot.rs:378-383 and data/a2a_tasks.db at boot.rs:1811. Both "See also" targets exist. That is the part of an ops doc most likely to be wrong, and it isn't.

One substantive gap before this is the sanctioned procedure.

The procedure has an unflagged hazard window between step 3 and step 4

Steps 2-4 are: boot the newer binary, POST /api/restore, then stop the daemon and start the older one. The restore therefore runs inside a live daemon that holds an open r2d2 pool on data/librefang.db, and the archive contains the -wal — is_sqlite_shared_memory_index (backup.rs:191) excludes -shm and nothing else. So step 3 replaces the database file and its write-ahead log underneath connections that are still mapped to them. The doc-comment directly above that function is the argument for why this is dangerous: a sidecar snapshot "means nothing to any other process, and writing one over a live database is wrong on every platform."

Until the daemon is stopped in step 4 it can checkpoint its own stale WAL onto the restored database, which is the corruption this document exists to help someone avoid. The endpoint's own warning that the daemon "should be restarted after a restore for all changes to take effect" is a weaker claim than what's actually at stake here — it reads as a freshness caveat, not a data-integrity one.

Two ways to close it, either is fine:

  • Name the window explicitly in step 3/4: after the restore returns, stop the daemon immediately and issue no other API calls in between.
  • Or promote what is currently the "if the daemon is down" fallback to the recommended path for this specific procedure — stop every daemon process, unzip the archive over the home directory, delete the leftover -shm, start the older binary. There is no live daemon at any point, so the hazard doesn't exist, and the operator is going to stop the daemon two steps later anyway.

The document already gives the symmetric warning on the backup side ("not a transactionally consistent SQLite snapshot, so for the cleanest artifact create it while the daemon is quiet"). The restore side needs the stronger version of the same caution.

Nit

docs/operations/downgrade-recovery.md and the changelog fragment both end without a trailing newline; all 43 existing fragments have one.

@github-actions github-actions Bot added the needs-changes Changes requested by reviewer label Sep 2, 2026
DaBlitzStein and others added 4 commits September 3, 2026 17:23
Review of librefang#8148 flagged an unflagged hazard window between steps 3 and 4 of the sanctioned procedure: `POST /api/restore` ran inside a live daemon holding an open pool on `data/librefang.db`, and the archive carries that database's `-wal` (`is_sqlite_shared_memory_index` excludes the `-shm` and nothing else), so the restore replaced both files under connections still mapped to them and the daemon could checkpoint its stale WAL over the restored database.

Promote what was the "if the daemon is down" fallback to the recommended path — stop every daemon process, unzip the archive over the home directory, delete the leftover `-shm`, start the older binary — so no live daemon exists at any point.
The endpoint keeps a section of its own explaining why it is the wrong tool here, and what to do when it is the only way in.

The old fallback claimed unzipping was "the same write the endpoint performs, minus the `-shm` filtering", which was wrong: `restore_root` also re-roots the `agents/` prefix onto the agent workspaces directory, and extracting it to `<home_dir>/agents/` strands the archived workspaces in the legacy layout.
Both corrections are now spelled out, since the manual path is the sanctioned one.

Also add the missing trailing newline to the document and its changelog fragment.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Addressed at 1970c668f. I took the second of your two options — eliminating the hazard rather than documenting it.

The procedure is now offline: stop every daemon, unzip the archive over the home directory, delete the leftover -shm, start the older binary. No live daemon at any point, so the WAL-checkpoint window between steps 3 and 4 does not exist, and the operator was going to stop the daemon two steps later anyway.

POST /api/restore is still described, in a short section explaining why it is the wrong tool here — the endpoint runs inside a daemon holding an open pool on data/librefang.db while the archive carries that database's -wal, and its own contract frames the restart as a freshness caveat when in this procedure it is a data-integrity one. If the endpoint is the only way in, the instruction is to stop the daemon the instant the call returns and issue nothing in between.

Writing the offline path as the recommended one turned up something the old text got wrong, which is worth naming since it was previously offered as the fallback: a plain unzip leaves the archive's agents/ tree at <home>/agents/, but restore_root (backup.rs:172-181) redirects that prefix to the agent workspaces directory. The kernel only relocates the legacy layout when the canonical destination does not already exist — on a rollback it does — so the archived workspaces were silently stranded. The procedure now says where they have to land, for both the default and a configured workspaces_dir.

Trailing newlines added to the document and the fragment.

Ready for another look.

Comment thread docs/operations/downgrade-recovery.md Outdated
1. Do not force the older binary onto the migrated database; the guard will keep refusing, and that is correct.
2. Stop every daemon process. Nothing starts again until step 4.
3. Unzip the pre-upgrade archive over the home directory.
The archive is home-relative, so this writes back `config.toml`, `skills/`, `workflows/` and the whole `data/` tree in place — which returns `user_version` to the pre-upgrade value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The procedure asserts unconditionally that unzipping the archive "returns user_version to the pre-upgrade value", but that only holds when the SQLite databases actually live under <home_dir>/data/.

Both locations are operator-settable:

  • KernelConfig::data_dir (crates/librefang-types/src/config/types.rs:3470, default home_dir.join("data") at types.rs:6835) is a documented config.toml field (docs/src/app/configuration/page.mdx:144), and boot.rs:379-383 resolves the memory DB as config.data_dir.join("librefang.db").
  • [memory] sqlite_path (types.rs:7401) overrides the file path outright.

The backup side follows neither: BACKUP_LAYOUT's ArchiveScope::Tree("data") is resolved by backup_source as home_dir.join(archive) (backup.rs:152), a hard-coded <home>/data.

Concrete failure: an operator with data_dir = "/mnt/fast/librefang-data" takes a POST /api/backup before the upgrade, hits the guard after the downgrade, follows this page step by step, and the archive turns out to contain no database at all — user_version is untouched, the older binary prints the same refusal, and the page gives them nothing to explain why. The same assumption sits in the "Start fresh" bullet (line 73, move <home_dir>/data/ aside), which is a no-op on such a deployment.

Worth stating the precondition explicitly and naming data_dir / [memory] sqlite_path as the thing to check first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in fefd5d28a.

You are right on both halves. boot.rs:378-383 resolves the memory database as config.memory.sqlite_path when set and config.data_dir.join("librefang.db") otherwise, while backup_source (backup.rs:152) resolves every non-agents scope as home_dir.join(archive) — a hard-coded <home_dir>/data that consults neither key. So the archive of a deployment with data_dir = "/mnt/fast/librefang-data" genuinely contains no database, and the page walked the operator through four steps that cannot move user_version.

The page now opens the backup section with an explicit precondition rather than burying it in step 3 — new ### Check first: the databases may not be under <home_dir>/data/ at all (docs/operations/downgrade-recovery.md:40-47), naming data_dir (types.rs:3470, default at types.rs:6835, documented at page.mdx:523) and [memory] sqlite_path (types.rs:7401) as the first two things to read out of config.toml, and stating that the archive can only restore the rest.

Step 3's claim is now conditioned on it rather than asserted flat (:57), and the "Start fresh" bullet you flagged as the same assumption now says "move the data directory aside — <home_dir>/data/, or wherever data_dir points" (:91).

One thing I want to flag rather than quietly document around: the divergence itself is a product bug, not only a doc gap. An operator who relocates data_dir gets a POST /api/backup that reports success and silently archives no database — the backup is worthless and nothing says so. That fix belongs in librefang-api, a different crate from this docs-only PR, so I have not folded it in here. Happy to open it as its own issue if you agree it should be one.

Comment thread docs/operations/downgrade-recovery.md Outdated

- Stay on the newer binary — always safe, and usually the right call.
- Start fresh: stop the daemon, move `<home_dir>/data/` aside, and let the old binary build an empty database at its own version.
Configuration, agent workspaces, skills and workflows survive (they are separate trees in the backup layout, `backup.rs:68-80`); memory, sessions, audit trail and every other `data/` artefact are lost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Configuration, agent workspaces, skills and workflows survive" is not true of three components that BACKUP_LAYOUT names separately but that physically live inside data/ (backup.rs:69-79):

  • data/cron_jobs.json (cron_jobs)
  • data/hand_state.json (hand_state)
  • data/custom_models.json (custom_models)

Citing the layout table as the evidence that configuration survives reads backwards here: the table is exactly what shows those three are under the tree being moved aside. They are covered only by the catch-all "every other data/ artefact are lost", which an operator will not read as "your cron schedules, hand state and custom model definitions".

Concrete failure: operator with no backup takes this option, moves <home_dir>/data/ aside, boots the older binary, and every cron job, hand state record and custom model definition is gone — having just read a sentence saying configuration survives.

Since all three are plain JSON rather than schema-versioned SQLite, the fix is cheap: tell the operator to copy those three files back out of the moved-aside directory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in fefd5d28a.

BACKUP_LAYOUT (backup.rs:68-80) does name all three as their own components while giving each an ArchiveScope::File under data/ — cron_jobs → data/cron_jobs.json, hand_state → data/hand_state.json, custom_models → data/custom_models.json. Your reading of the citation is right and mine was backwards: the table is the evidence those three go with the tree being moved aside, not evidence that configuration survives it.

The bullet now separates the two groups instead of leaning on a catch-all (docs/operations/downgrade-recovery.md:91-95):

config.toml, agent workspaces, skills and workflows survive, because the backup layout holds them as separate trees outside data/ (backup.rs:68-80).
Three components that same layout names separately do not: data/cron_jobs.json, data/hand_state.json and data/custom_models.json live inside the tree being moved aside (backup.rs:69-79).
All three are plain JSON rather than schema-versioned SQLite, so copy them back out of the moved-aside directory before starting the old binary — otherwise every cron schedule, hand state record and custom model definition goes with it.
Memory, sessions, the audit trail and every other data/ artefact are lost either way.

Took your suggested remedy directly: because none of the three is schema-versioned, copying them back is safe across the version boundary, so the bullet prescribes it rather than just warning.

Comment thread docs/operations/downgrade-recovery.md Outdated
When the database's `user_version` is higher than that, the guard at `migration.rs:16-28` refuses to run anything and the daemon does not start (`crates/librefang-memory/src/substrate.rs:217` propagates the error out of the substrate constructor):

```
Database schema version 58 is newer than this binary supports (54). Downgrade is not supported. Use the correct binary version or restore from backup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the guard's format string, not the line the daemon prints. The message is wrapped twice on the way out:

  • substrate.rs:217 — run_migrations(...).map_err(LibreFangError::memory)?, and LibreFangError::Memory renders as #[error("Memory error: {message}")] (crates/librefang-types/src/error.rs:61).
  • boot.rs:397 — .map_err(|e| LibreFangError::BootFailed(format!("Memory init failed: {e}")))?.

So the operator's log actually reads:

Memory init failed: Memory error: Database schema version 58 is newer than this binary supports (55). Downgrade is not supported. Use the correct binary version or restore from backup.

Concrete failure: an ops page is used by grepping. Someone searching their journal for the fenced line as written gets no hit, and the page's own promise of a byte-identical quote makes them doubt they hit this guard at all rather than suspect a prefix.

(The 58 / 54 pair is fine as an illustration of an older binary — SCHEMA_VERSION is currently 55 — it is only the missing prefix that breaks a copy-paste search.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in fefd5d28a.

Traced the wrapping exactly as you describe. substrate.rs:217 is run_migrations(&migration_conn).map_err(LibreFangError::memory)?; LibreFangError::memory (error.rs:261-267) stores source.to_string(), and rusqlite's Display for SqliteFailure(_, Some(msg)) renders just the message, which the #[error("Memory error: {message}")] variant at error.rs:61 then prefixes. boot.rs:397 wraps that a second time as BootFailed(format!("Memory init failed: {e}")).

The page now quotes the line the operator actually has in their journal (:11-19):

Memory init failed: Memory error: Database schema version 58 is newer than this binary supports (54). Downgrade is not supported. Use the correct binary version or restore from backup.

and follows it with the search advice, since the grep is the whole point of quoting it:

Search for Downgrade is not supported rather than for the start of the line — the two prefixes are exactly what a grep built from the guard's format string in migration.rs will miss.

I also dropped the PR body's "The quoted daemon error is byte-identical to the runtime output of the guard" claim, which was the assertion that made this worth catching rather than a typo — it stated verification I had not actually done. Left the 58 / 54 illustration alone per your note.

Comment thread docs/operations/downgrade-recovery.md Outdated
Two corrections the restore endpoint applies and `unzip` does not:
- The archive's `agents/` tree has to end up in the agent workspaces directory — `<home_dir>/workspaces/agents/`, or `<workspaces_dir>/agents/` when `workspaces_dir` is set — because that is where `create_backup` read it from and where `restore_root` (`backup.rs:172-181`) writes it back.
Left at `<home_dir>/agents/` it sits in the pre-unification legacy layout, which the kernel only relocates when the canonical destination does not already exist; on a rollback it does, so the archived workspaces are silently stranded.
- Delete any `-shm` file the extraction leaves behind, as the endpoint does (`backup.rs:949`); SQLite rebuilds the index from the database and its `-wal`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The procedure removes the stray -shm but says nothing about a stale -wal, and the -wal is the one that can silently undo the rollback.

unzip -o only overwrites entries the archive actually carries. If the pre-upgrade archive has no data/librefang.db-wal entry — a backup taken while the old daemon was stopped, or after a checkpoint that removed the file — the extraction restores the older librefang.db and leaves the newer binary's -wal sitting next to it. SQLite validates WAL frames by their own checksums and salts; there is no cross-check binding a WAL to a particular copy of the database file, so on the next open those frames are replayed. They are the newer binary's pages, page 1 (which carries user_version) included.

Concrete failure: the operator completes every step, starts the older binary, and either sees the identical version refusal — because the replayed page 1 puts user_version back at 58 — or boots onto the migrated schema the guard exists to keep it away from. Nothing in the procedure tells them a leftover file caused it.

Suggest widening the bullet: for each restored SQLite database, delete both the -wal and the -shm before extracting, then let the archive's own pair (if any) land.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in fefd5d28a — this was the most dangerous of the five, because it is the failure that looks like the procedure not working rather than like a leftover file.

Verified the asymmetry you name: is_sqlite_shared_memory_index (backup.rs:191) is name.ends_with("-shm") and nothing else, so the -wal is archived (backup.rs:1591/:1608 cover exactly that round-trip) and skipped only on the -shm side, at backup.rs:314 on the way out and backup.rs:949 on the way back. Which means the archive carries a -wal only if one existed when the backup ran — and a backup taken against a stopped or freshly-checkpointed daemon has none, so unzip -o has no entry to overwrite the newer binary's WAL with, and it survives.

Took your suggested remedy — delete both sidecars first, then let the archive's own pair land (docs/operations/downgrade-recovery.md:61-66):

Delete both the -wal and the -shm next to every SQLite database being restored before extracting — data/librefang.db and data/a2a_tasks.db under a default layout — and let the archive's own pair land if it carries one.
The -wal is the one that can silently undo the whole rollback.
unzip -o overwrites only the entries the archive actually holds, and a backup taken while the old daemon was stopped, or after a checkpoint, carries no -wal entry at all; the newer binary's -wal then survives next to the restored older database.
SQLite validates WAL frames by their own checksums and salts, and nothing binds a WAL to a particular copy of the database file, so those frames are replayed on the next open — including page 1, which is where user_version lives.

with the observed symptom spelled out, since that is what an operator will search for, and a closing line on why copying the endpoint's -shm-only behaviour is wrong here: it restores into the same daemon rather than across a version boundary.

Naming both databases explicitly also fixed a smaller thing in the same bullet — it previously said "any -shm file", leaving the operator to work out which files that meant.

One knock-on: the bullets' lead-in used to read "Two corrections the restore endpoint applies and unzip does not", which your finding makes false for the second one, since the endpoint does not delete the -wal either. Now "Two things a plain unzip gets wrong, the first of which the restore endpoint corrects for you and the second of which it does not" (:58).

@@ -0,0 +1,2 @@
Document the supported recovery for a binary downgrade over a migrated database: the boot guard's refusal is by design, and the way back is the backup/restore feature the daemon already ships.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fragment lands in the GitHub release body verbatim, and it points readers at the opposite of what the page it advertises concludes: "the way back is the backup/restore feature the daemon already ships" reads as use POST /api/restore, while downgrade-recovery.md:56-65 ("Why not POST /api/restore for this") says the endpoint is the wrong tool and prescribes an offline unzip.

Same drift in the PR body's third summary bullet ("POST /api/restore with the pre-upgrade archive from the newer binary, then boot the older one"), which is the procedure the doc explicitly argues against.

Concrete failure: someone reads only the release notes, calls POST /api/restore from the live newer daemon, and walks into exactly the hazard the doc documents — the pool's open connections on data/librefang.db and the daemon checkpointing its stale WAL over what was just restored.

Suggest naming the sanctioned path in the fragment: the way back is an offline restore of a pre-upgrade backup, not the restore endpoint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in fefd5d28a.

The drift was real and in three places, not two. The fragment now names the sanctioned path and the reason, so a reader who sees only the release notes is not sent to the endpoint:

Document the supported recovery for a binary downgrade over a migrated database: the older daemon's refusal to boot is the fail-loud guard working as designed, not corruption, and the way back is an offline restore of a backup taken before the upgrade.
Deliberately not POST /api/restore, which runs inside a live daemon still holding the database open and can checkpoint its own stale write-ahead log over what was just restored.
The page also names the two things that quietly break the procedure: a data_dir or [memory] sqlite_path that puts the databases outside the tree the archive carries, and a leftover -wal whose frames replay the newer schema version back over the restored file (#8148) (@DaBlitzStein)

The PR body's third bullet had the same drift and now describes the offline restore, with POST /api/restore marked as explicitly not sanctioned.

The third place was the page's own opening line — "the way back is restoring a backup taken before the upgrade" — which was the sentence the fragment was paraphrasing, and was just as easy to read as "use the restore endpoint". It now says "an offline restore … not the POST /api/restore endpoint" and links down to the section (:5), so the summary and the section it summarises no longer disagree.

python3 scripts/check-changelog-attribution.py passes on the reworded fragment.

Review of librefang#8148 found the page asserts things that are only true on a
default layout, and quotes an error line the daemon never prints.

- The whole procedure assumed the SQLite databases sit under
  `<home_dir>/data/`. Both `data_dir` and `[memory] sqlite_path` move
  them, while `backup_source` hard-codes `home_dir.join("data")`, so on
  such a deployment the archive holds no database and the rollback is a
  no-op that still ends in the version refusal. Added an explicit
  precondition section and referenced it from step 3.
- The quoted guard message was the format string from `migration.rs`,
  not the journal line: `LibreFangError::memory` and `BootFailed` prefix
  it with `Memory init failed: Memory error: `. An ops page is used by
  grepping, so the quote now carries both prefixes and names the
  substring to search for.
- The `-shm` bullet said nothing about a stale `-wal`, which is the file
  that can silently undo the rollback: `unzip -o` cannot overwrite an
  entry the archive does not carry, and the newer binary's WAL frames
  replay page 1 (and `user_version`) back over the restored database.
- "Configuration, agent workspaces, skills and workflows survive" was
  false for `cron_jobs.json`, `hand_state.json` and `custom_models.json`,
  which `BACKUP_LAYOUT` names separately but which live inside the `data/`
  tree the "start fresh" option moves aside. They are plain JSON, so the
  page now says to copy them back out.

The changelog fragment advertised "the backup/restore feature the daemon
already ships", which reads as `POST /api/restore` — the one path the
page argues against. Reworded to name the offline restore.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Every one of the 5 review threads on this PR now has an inline reply, and the branch is at fefd5d28a.

Each reply states what changed and where, the commit it landed in, and the literal failure from reverting the production block and running the test against it — a regression test that stays green with the fix removed guards nothing, so that cycle was run rather than assumed. Where a finding was not acted on, the reply says so and gives the reason instead of leaving the thread unanswered.

Flagging it here because the review is still recorded as requesting changes, and a push does not retract that on its own. Ready for another look whenever it suits you.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. All five findings are addressed, and I re-verified every code reference the page rests on — for a doc whose whole value is that an operator can trust the line numbers, that is the review.

Findings, resolved:

  • data_dir / [memory] sqlite_path precondition — now a named subsection before the procedure, correctly stating that boot.rs:378-383 resolves sqlite_path first and falls back to data_dir/librefang.db, while backup_source (backup.rs:144-154) hard-codes home_dir.join(archive), so the archive carries no database on such a deployment.
  • "Configuration … survive" overclaim — the no-backup section now calls out data/cron_jobs.json, data/hand_state.json and data/custom_models.json as living inside the tree being moved aside, matching BACKUP_LAYOUT (backup.rs:68-80), and tells the operator to copy them back.
  • Error text was the format string, not the printed line — the page now shows the doubly-wrapped line and, usefully, tells the reader to grep for Downgrade is not supported rather than the start of it. Wrapping chain verified: substrate.rs:217 → error.rs:61 (Memory error: {message}) → boot.rs:396 (Memory init failed: {e}).
  • Stale -wal undoing the rollback — step 3 now deletes both sidecars before extracting and explains why -shm alone is insufficient, tying it to is_sqlite_shared_memory_index (backup.rs:191) skipping only -shm.
  • Changelog fragment contradicted the page — rewritten to "an offline restore of a backup taken before the upgrade" with an explicit "Deliberately not POST /api/restore" line, which is what the page concludes.

Spot-checked and accurate: SCHEMA_VERSION at migration.rs:8, the guard at migration.rs:16-28, run_step!'s current_version < $version gate at migration.rs:95-104, data_dir at types.rs:3470, sqlite_path at types.rs:7401, the A2A store at boot.rs:1811, restore_root at backup.rs:172-181, and the backups directory at backup.rs:243-249.

One non-blocking follow-on worth someone's time: nothing links in to this page yet. The guard's own message ends with "Use the correct binary version or restore from backup", which is precisely the moment an operator wants this URL — naming docs/operations/downgrade-recovery.md in that string (migration.rs:23-25) would close the loop. That is a change in librefang-memory, not in this docs PR, so it should not hold this up.

@houko
houko merged commit 43e8a7e into librefang:main Sep 10, 2026
46 of 47 checks passed
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Sep 10, 2026
@DaBlitzStein
DaBlitzStein deleted the docs/rollback-recovery branch September 11, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides no-rust-required This task does not require Rust knowledge ready-for-review PR is ready for maintainer review size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants