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 β stampedMealRefnames are the exception. - The
meal_rotasingleton rides along with itsreminder_last_sentbookkeeping 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
ExportModeonExportData: no new RPC, but the per-RPC matrix cannot split "admin exports everything" from "member exports themself". Rejected. UserServiceplacement: 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.