App reference

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.Scrub runs the derive pass too, so portable User rows carry hasPin: true residue 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.
  • ItemList rows export without their read-time items projection β€” 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.go must 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 formatVersion 1 (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.