App reference

ADR-0023: Data import β€” restore and wipe-and-replace

Metadata

  • Status: Accepted
  • Date: 2026-08-08
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0006/0018 (setup window), ADR-0021 (dual-backend storage), ADR-0022 (export format β€” this ADR is its counterpart)

Context

ADR-0022 shipped restore-capable backup archives with nothing to restore them: disaster recovery and migration both dead-ended at a .json.gz. Two situations need the missing half. A family standing up a fresh server after a disaster (or moving house between installs) needs to load a backup before anyone can sign in. An admin on a live install needs a deliberate do-over β€” replace everything with an earlier backup.

The auth shape is the interesting part: on an empty install there is nobody to authenticate, and on a populated install a whole-database replacement is the most destructive operation the product has.

Decision

One RPC, SystemService/ImportData, restoring backup-mode archives only β€” a portable archive has no password hashes, no signing secret and no sessions, so restoring it would create a household nobody can sign into. Import refuses it with an error that says to use a full backup.

Two-headed authorization. While the install has no users, ImportData is a public setup window exactly like UserService/Create (ADR-0018): the endpoint is on the public list, and when EAG_INITIAL_ADMIN_TOKEN is configured the X-Initial-Admin-Token header is required β€” on production installs the window is therefore token-gated, not open. The moment any user exists the handler demands an authenticated admin AND an explicit wipe flag, with distinct refusals for "not an admin" and "set wipe to replace". The PermissionMatrix entry is Admin; the public-list bypass exists solely for the window, mirroring the create window's construction.

The transaction always clears every table before inserting. A "fresh" install is not row-empty β€” boot seeds the system_settings row β€” so insert-into-empty would collide on uid system. Clearing unconditionally makes the wipe flag consent, not mechanism. TxTable.DeleteAll (plain DELETE FROM, standard SQL on both engines) is the wipe primitive and is reserved for this path.

Every wipe leaves an undo artifact by construction. When data is being replaced, the import transaction first dumps the old data (the export dumper, run transaction-side) and returns it in the response as pre_wipe_archive. The web UI downloads it before anything else; the CLI writes it to disk. This extends ADR-0022's opaque-bytes sanitize bypass to a second field β€” the reservation in the development guide now names both, and it must not grow further without an ADR.

Strict, loud compatibility. formatVersion must be exactly 1. An archive naming a table this server does not know is refused ("comes from a newer EagraΓ­ Clainne β€” upgrade this server first"); missing tables restore empty (an older archive into a newer server is fine). Rows unmarshal with strict protojson β€” an unknown field refuses, naming the table β€” and a duplicate uid inside the archive is an error. The whole import is one transaction: all or nothing. A restore that silently sheds data is worse than one that fails.

Decompression is capped (1 GiB) so a gzip bomb on the anonymous window cannot eat the server's memory β€” defence in depth behind the setup token.

After commit: settings.Store.Reload swaps the cached settings so auth signs and validates with the restored secret from the next request on; changes.Announce fires for every entity so open clients refresh; an audit entry (MUTATION_ACTION_IMPORT, labeled "data import" or "wipe and import") lands in the restored audit log β€” anonymous actor on a setup-window restore, per the ADR-0018 contract.

Sessions restore verbatim. An install restored from backup is that install: archived refresh cookies resume (digest match + restored secret + matching token generation), and pre-wipe cookies die at their next refresh. The web wipe flow signs the admin out deliberately, like secret rotation.

Registry. Export and import now share one per-table registry (internal/services/system/registry.go): dump, transactional dump, wipe and insert per table, in one list. That list is the single place a new table must be wired for the archive lifecycle; the export unit test counts it against the schema.

Surfaces. Web Welcome screen ("Restore from backup", reusing the setup token field), web Admin System tab ("Replace all data" behind a type-to-confirm and a danger button β€” the destructive-button shape used as intended), CLI (eagraiclainne system import <file> [--wipe]). Android has no Admin tier; MCP deliberately keeps wipe-capable operations off the assistant surface.

Consequences

  • On Postgres, a first-admin creation racing a setup-window import can commit between the import's in-transaction count and its commit, surviving alongside restored rows. The window is milliseconds, setup-only and admin-shaped; ADR-0018 accepts the same race for the create window. On SQLite (the default) _txlock=immediate serialises the two entirely β€” the race does not exist.
  • The whole archive (compressed + decompressed + parsed, plus the pre-wipe dump on the wipe path) lives in memory for the duration of the call. Family-scale fine; streaming is future work alongside ADR-0022's.
  • A wipe restore from a different install's backup orphans every current session, the importing admin's included β€” the web flow downloads the pre-wipe backup and signs out deliberately.
  • pkg/client.SystemService.ImportData builds its request by hand instead of the invoke funnel β€” the one call that needs a per-request header.

Alternatives considered

  • Merge into existing data: a sync feature wearing import's clothes β€” uid collisions, settings singleton conflicts, two founding admins. Rejected for this change; the export/import format does not preclude it.
  • Always-admin (no setup window): restore would need a throwaway founding admin created first, then wiped by the import. Clunkier ritual for the disaster case, no security gain over the token-gated window.
  • Lenient row parsing (DiscardUnknown): restores more often, silently sheds data from newer archives. Rejected β€” failure honesty.
  • Accepting portable archives: would fabricate credential-less accounts. Rejected.