Skip to content

feat: Add atuin store repair#3429

Open
davidolrik wants to merge 1 commit into
atuinsh:mainfrom
davidolrik:atuin-store-repair
Open

feat: Add atuin store repair#3429
davidolrik wants to merge 1 commit into
atuinsh:mainfrom
davidolrik:atuin-store-repair

Conversation

@davidolrik

Copy link
Copy Markdown
Contributor

I got bitten by the same issue describe by multiple users in this thread, not because sync is broken in any way, but because of a failed attempt to script the login to my atuin server.

I tried @ellie's proposed solution described here, but as I have atuin running on a lot of servers, all of which auto syncs, I deemed it too much work to disable auto sync, sync up and down on all of them and then turning on auto sync again. Every time I thought I had synced everything correctly, the undecryptable records turned up again, and the atuin store push --force multiplied the issue due to the complete rewrite of the server database.

So instead I wrote atuin store repair.
As I am not a rust expert, I had a bit of help from Claude.

I'm currently running this branch on my own server, and I have successfully repaired the history on all of my servers.

How it works

atuin store repair surgically replaces the encrypted payload of each undecryptable record with a decryptable no-op — a HistoryRecord::Delete pointing at a freshly-minted UUID that does not match any real history entry.

atuin store repair is idempotent at every level:

Re-running on the same host, same state

Step 1 scans the local store for records that fail to decrypt with the current key. After a successful repair, every record decrypts, so the bad set is empty and the command exits with "Nothing to repair." No server traffic, no local writes.

Re-running after another host already fixed the server

When host A repairs first, the server holds the good replacement. When host B runs repair, fetch_server_view pulls that record, resolve_replacement takes the ServerHadClean branch, and B overwrites its local copy from the server. B pushes nothing — to_push stays empty for records already fixed remotely.

Re-running mid-repair after a crash

The server UPDATE is keyed on (user_id, client_id) and replaces data/cek wholesale. Applying the same replacement twice yields the same row state — the second UPDATE just writes identical bytes. Locally, delete(id) + push_batch(replacement) likewise converges: the row either didn't exist yet (insert) or gets dropped and re-inserted with the same contents.

The Delete no-op itself is idempotent

The replacement payload is HistoryRecord::Delete(random_uuid). When any host processes this during incremental_build, it calls database.delete_rows([random_uuid]) — deleting a UUID that doesn't match any history row is a no-op, and deleting it repeatedly remains a no-op.

What preserves idempotency

  • (id, host, idx, version, tag, timestamp) are never touched, so PASETO implicit assertions remain valid across any number of replays.
  • The random UUID inside the Delete payload is generated once per bad record during the first successful repair; subsequent hosts adopt the server's existing replacement rather than minting a new one, so the payload converges across the fleet.

On each host repair does the following:

  1. Scan the local record store for records that fail to decrypt with the current key.
  2. Fetch the server's current version of each affected (host, tag) range. If the server already holds a decryptable replacement (because another host already
    repaired it), adopt that one locally.
  3. Otherwise generate a replacement, push it to the server via the new POST /api/v0/record/repair endpoint, and overwrite the local copy.
  4. Verify the local store now decrypts cleanly with the current key.

The first host to run repair fixes the server; subsequent hosts just pull the fix down. --local-only skips the server round trip for offline use.

Changes introduced

  • New server endpoint POST /api/v0/record/repair
  • New sub command atuin store repair

Scope

  • This is the only code path that mutates existing rows in the record store; it's documented as such on the Database trait.
  • No migration, schema change, or data-format version bump beyond the new index.
  • The original history commands encrypted with the lost key are unrecoverable — repair just unblocks sync.

Checks

  • I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle
  • I have checked that there are no existing pull requests for the same thing

@greptile-apps

greptile-apps Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds atuin store repair, a surgical tool to replace undecryptable records (from a botched key rotation) with decryptable HistoryRecord::Delete no-ops, preserving all metadata so PASETO assertions and idx chains stay intact. The design is idempotent, multi-host safe, and backed by a new server endpoint with correct ownership scoping and a supporting index migration.

Confidence Score: 5/5

Safe to merge; all findings are P2 style concerns.

The security boundary (UPDATE scoped to user_id), idempotency guarantees, and index migration are all correct. The only flagged issue is a future-proofing concern about non-history tags that doesn't affect any current code path.

crates/atuin-client/src/history/store.rs — the non-history-tag bail in build_history_repair_replacement.

Important Files Changed

Filename Overview
crates/atuin/src/command/client/store/repair.rs Main repair command: scan → fetch server view → resolve → push → overwrite locally → verify. Logic is correct and idempotent.
crates/atuin-client/src/history/store.rs Adds build_history_repair_replacement; correctly preserves id/idx/host/tag/version/timestamp for PASETO. Bails on non-history tags — safe today but could abort repair for future tags.
crates/atuin-server/src/handlers/v0/record.rs Adds repair handler with auth, max_record_size check, and delegation to DB trait. Mirrors the existing post handler appropriately.
crates/atuin-server-database/src/lib.rs New repair_records trait method with clear doc comment about ownership semantics and append-only exception. Well-specified.
crates/atuin-server-postgres/src/lib.rs Parameterized UPDATE scoped to (client_id, user_id); only data/cek columns touched. Correct and safe.
crates/atuin-server-sqlite/src/lib.rs Identical pattern to the Postgres implementation. Correct.
crates/atuin-server-postgres/migrations/20260416000000_store-user-client-idx.sql Adds store_user_client index on (user_id, client_id) — necessary for the UPDATE to avoid full table scans.
crates/atuin-server-sqlite/migrations/20260416000000_store-user-client-idx.sql Same index for the SQLite backend.
crates/atuin-client/src/api_client.rs New repair_records client method POSTing to /api/v0/record/repair. Consistent with existing transport helpers.
crates/atuin-server/src/router.rs Adds POST /api/v0/record/repair route behind the existing auth middleware.
crates/atuin/tests/repair.rs Integration tests covering key replacement, metadata preservation, wrong-key detection, and idempotency.

Reviews (1): Last reviewed commit: "feat: Add atuin store repair" | Re-trigger Greptile

@davidolrik davidolrik force-pushed the atuin-store-repair branch 3 times, most recently from ef748f1 to 82c8bfe Compare April 29, 2026 12:24
@davidolrik davidolrik force-pushed the atuin-store-repair branch from 82c8bfe to a02d2af Compare May 16, 2026 20:35
@atuin-bot

Copy link
Copy Markdown

This pull request has been mentioned on Atuin Community. There might be relevant details there:

https://forum.atuin.sh/t/key-management-user-experience/1442/4

@davidolrik davidolrik force-pushed the atuin-store-repair branch 2 times, most recently from a24f144 to 1f529e8 Compare June 7, 2026 12:51
@davidolrik davidolrik force-pushed the atuin-store-repair branch from 1f529e8 to eba150d Compare June 12, 2026 21:49
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.

2 participants