App reference

ADR-0027: Per-user data export

Metadata

  • Status: Accepted
  • Date: 2026-08-09
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0002 (centralised RPC authorization), ADR-0016 (API keys and credential attribution), ADR-0022 (data export), ADR-0023 (data import)

Context

ADR-0022 gave the install an export, but only the admin can pull it and it covers the whole household. A member owns their data individually, not only collectively: a teenager should be able to take their own history with them, and a parent should be able to answer a data request for a child without handing over everyone else's records too. ADR-0022 recorded this as future work ("PORTABLE filtered to one member's data, available to the member themself").

The authorization system makes the obvious shapes awkward. The PermissionMatrix is per-RPC: it can open an endpoint to a role, but it cannot express "any member, for their own uid only". A new mode on ExportData would therefore either stay admin-only or open the full export to everyone.

Decision

One new RPC, SystemService/ExportUserData, returning the ADR-0022 archive shape scoped to one member.

Authorization. The matrix entry allows every signed-in role. The handler enforces the target: an empty user_uid means the caller themself; a non-self target requires ADMIN (svc.RequireAdmin). A standalone API key acting for nobody has no "self" and is refused (CodeFailedPrecondition, the ADR-0016 pattern) unless it names a target and holds ADMIN. An unknown target is CodeNotFound, not an empty archive.

Scope β€” owned + participating. The archive keeps, per table:

table keep a row when
user it is the target's own record
item owner is the target
event the target is in users
reward user is the target
meal_rota any slot's cook is the target
meal_override cook is the target
meal_week any frozen day's cook is the target
notification recipient is the target
audit actor or subject is the target, or the entry is about the target (entity_type "user" + their uid)

Excluded wholesale: api_key, session, push_subscription, webhook (plumbing and credential material), system_settings (install-wide, nobody's personal data), meal (shared library, no user refs), item_list (no ownership; list names already ride on each item's ListRef).

The include/exclude split lives in internal/services/system/exportuser.go (userTables / userScopeExcluded) and a completeness test checks it covers the ADR-0022 registry exactly β€” a new table cannot land without a per-user scope decision, the mirror of the registry rule.

Always scrubbed. Rows pass svc.Scrub unconditionally β€” there is no secrets-bearing variant of this archive, whoever asks. The response reuses ExportDataResponse, so the ADR-0022 bytes bypass stays confined to the one message that already has it.

Not a restore source. The envelope is the ADR-0022 shape with mode "user" plus a subjectUid provenance field. ImportData's mode gate (backup only, ADR-0023) refuses it; a unit test pins that. A personal export can never wipe-restore an install.

Audit. audit.Flush() first, then an entry typed on the target (entity_type "user", the target's uid, label "personal data export", MUTATION_ACTION_EXPORT). Because the audit scope predicate matches entries about the target, an admin exporting someone's data shows up in that person's own future export β€” the transparency GDPR intends.

Surfaces. Web Settings carries the self-export card (reference), Web Admin the per-member action; Android Settings carries the self-export (daily tier); CLI eagraiclainne user export [--user UID] (maintenance tier); MCP gets export_my_data, self-only and size-capped β€” the deliberate, recorded exception to the ADR-0022 rule keeping archives off the assistant surface: install-level export and wipe stay off MCP, a self-scoped scrubbed personal export is assistant-safe.

Consequences

  • Shared entities are included whole: an event names its other attendees, a frozen meal week its other cooks. Household-internal data, and stored UserRefs are unnamed (hydration is read-time), so other members appear as bare uids β€” stamped MealRef names are the exception.
  • The meal_rota singleton rides along with its reminder_last_sent bookkeeping when the target cooks on it. Harmless in a scrubbed archive.
  • Audit entries where the target acted on someone else's entity are included (they are the target's actions); the entity uid in them may reference another member's record. Same household-internal judgment as above.
  • This is an export, not erasure β€” no right-to-be-forgotten mechanics here.
  • A Guest can export their own (near-empty) data. Consistent, and simpler than a carve-out.
  • In-memory filtering reads each table fully and drops non-matching rows β€” family-scale fine, same envelope-in-memory caveat as ADR-0022.

Alternatives considered

  • New ExportMode on ExportData: no new RPC, but the per-RPC matrix cannot split "admin exports everything" from "member exports themself". Rejected.
  • UserService placement: the archive machinery, envelope and bytes bypass live in the system service; splitting them across services spreads the bypass. Rejected.
  • Redacting other members' refs inside shared entities: hand-rewriting rows breaks the "archive matches storage" property and adds a second scrub mechanism; refs are already unnamed in storage. Rejected.
  • Member-visible-everything scope (mirror read permissions): exports most of the household per member β€” the archive stops being their data. Rejected.