ADR-0022: Data export β backup and portable archives
Metadata
- Status: Accepted
- Date: 2026-08-08
- Deciders: EagraΓ Clainne Team
- Related: ADR-0002 (centralised RPC authorization), ADR-0010 (global audit log), ADR-0016 (API keys and credential attribution), ADR-0021 (dual-backend storage)
Context
A household owns its data, but the install had no way to hand it over: no backup before a risky change, no path to move a family to a new server, no portable copy to satisfy data-ownership expectations if the project ever serves households it does not live with. The audit log pages, but nothing exports.
Two of those needs pull in opposite directions. A disaster backup is only a backup if it restores everything β password hashes, the JWT signing secret, push-subscription keys, session digests. A portable copy is only safe to keep or hand over if it carries none of that. One artifact cannot be both.
The server invariant that makes this interesting is the
SanitizeInterceptor: sensitive-field scrubbing is structural, applied to
every RPC response, and rule 4 forbids clients bypassing the generated
surface. A backup RPC therefore has to carry secrets through a pipeline
built to remove them, without weakening that pipeline.
Decision
One admin-only RPC, SystemService/ExportData, with two modes, returning a
gzipped JSON envelope as opaque bytes.
Modes. EXPORT_MODE_BACKUP includes every table verbatim, secrets
included β restore-capable. EXPORT_MODE_PORTABLE includes the content
tables only (users, events, items, item lists, rewards, meals, meal rota,
meal overrides, meal weeks, notifications, audit entries, system settings)
and omits the plumbing tables wholesale: session, push_subscription,
api_key are device and machine credentials, worthless portably and risky
to hand out. Portable rows are scrubbed with svc.Scrub, a thin export of
the interceptor's own scrub map β one list of sensitive fields, however the
data leaves. EXPORT_MODE_UNSPECIFIED is rejected: secrets never ship
because a caller forgot to choose.
The bytes bypass. The archive travels in a bytes archive field. The
sanitize and hydrate interceptors walk protobuf message fields; a bytes
field is opaque to them. That is the deliberate, structural mechanism by
which BACKUP carries credential material out of the system while the
interceptor guarantee stays intact for every message-shaped response. It is
a documented mechanism, not a loophole: any future field that routes
sensitive data around the sanitizer must cite this ADR or extend the scrub
map instead.
Format. The envelope is
{formatVersion, exportedAt, mode, appVersion, tables} where tables maps
the internal/database table-name constants to arrays of rows in their
protojson storage form, sorted by uid. formatVersion starts at 1 and is
the contract a future import validates against. A row without a uid fails
the export loudly β an archive an import cannot key is not worth producing.
No in-app encryption. The archive is plain gzip. A passphrase-encrypted backup dies with the forgotten passphrase exactly when the family needs it; the admin already holds server root, so at-rest protection of the file is the operator's job (age/gpg if wanted). Transport is TLS.
Authorization and audit. ExportData has an admin-only
PermissionMatrix entry plus an in-handler svc.RequireAdmin guard. Every
export writes an audit entry (MUTATION_ACTION_EXPORT, labeled with the
mode) via audit.RecordLabeled β the log records that an archive left the
system, by whom, through which credential.
Surfaces. Web Admin (System tab: two download buttons) and CLI
(eagraiclainne system export --mode backup|portable), the CLI being the
cron-friendly path that makes routine backups realistic. Android carries no
Admin tier; MCP is skipped deliberately to keep full-archive pulls off the
assistant surface.
Consequences
- An admin session β or an admin-scoped API key β can pull the JWT secret and every password hash in one call. Accepted: that power is inherent to the admin role (it already includes secret rotation and password resets), and the audit entry makes each pull visible.
svc.Scrubruns the derive pass too, so portable User rows carryhasPin: trueresidue where a PIN exists. Honest, and harmless.- The protojson re-marshal is not byte-identical to the stored JSON (key
order may differ), but it is the same protojson form; import will
protojson.Unmarshal, so the round-trip is lossless. The format promises semantic fidelity, not byte fidelity. ItemListrows export without their read-timeitemsprojection β the archive matches storage, which is what restore needs, even if it surprises a human reader.- Portable archives still contain household history (audit entries name who did what); the UI copy says "safe to keep or hand over", and that is the household's judgment to make.
- The whole database is serialized and gzipped in memory in one unary response. Fine at family scale; the audit table is the unbounded grower, and a streaming variant is future work if it ever matters.
- The export table registry in
internal/services/system/export.gomust be extended when a table is added, or BACKUP silently stops being a full backup; the export unit test counts the registry against the schema to keep that failure loud.
Future work
- Import/restore consuming
formatVersion1 (empty-install restore first; uid-collision policy is that change's problem). - Per-member GDPR self-export: PORTABLE filtered to one member's data, available to the member themself.
Alternatives considered
- Raw HTTP download endpoint (like
/mcp): natural browser downloads, but it bypasses the PermissionMatrix and the generated client surface β exactly what rule 4 exists to prevent. Rejected. - Streaming RPC: the services are deliberately unary-only and the data is family-scale. Rejected as premature.
- Raw SQLite file copy (
VACUUM INTO): maximum fidelity, trivial restore β but only exists on the sqlite driver, leaving Postgres installs without a backup story, and portable mode still needs JSON. Rejected. - Passphrase-encrypted backup: safer file at rest, but key loss kills the disaster backup at the worst moment, and it adds crypto surface the operator can supply externally. Rejected.