App reference

ADR-0019: Client-minted uids make offline create replay safe

Metadata

  • Status: Accepted
  • Date: 2026-08-07
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0001 (JSONB storage and query convention), ADR-0016 (MCP server and API keys β€” the other machine caller of the create RPCs)

Context

The web surface is gaining full offline support: a per-user IndexedDB cache for reads and a single FIFO queue that records every household-data mutation made offline and replays it on reconnect. Android already ships a smaller queue (list check/uncheck/add) and will adopt the same design.

A replayed create is the one mutation that cannot be made safe client-side. The failure window is narrow but real: the create commits on the server, the network dies before the response lands, and the queue β€” which only knows the op is unconfirmed β€” sends it again. Every uid is minted server-side by svc.Create (UUIDv7), so the resend is a brand-new entity: a duplicate event, meal, or reward. Set-shaped mutations converge on resend (CompleteItem, SetDeadline, field-carrying Updates), and deletes can treat NotFound as success, but creates duplicate.

The storage layer compounds this: Table.Write is an UPSERT (ON CONFLICT ... DO UPDATE), so even a client-supplied uid would make a replayed create silently overwrite rather than fail β€” and an overwrite is a worse outcome than a duplicate, because it destroys the mutation trail appended after the first create.

Android's queue works around server-minted uids with local-<uuid> placeholder rows swapped for server uids during replay β€” machinery every queued cross-referencing op has to participate in.

Decision

  1. Create RPCs for household data accept an optional client-minted uid. CreateItemRequest, CreateItemListRequest, CreateEventRequest, CreateMealRequest and CreateRewardRequest gain a uid field. Empty keeps today's behaviour: the server mints.

  2. Only UUIDv7 is accepted. Entity uids are time-ordered by construction and the system leans on that (itemlist insertion ordering, audit paging); a client must not be able to break the invariant. Anything else β€” malformed, v4, nil β€” is InvalidArgument before any write. The seam is svc.CreateWithUID; svc.Create delegates to it with an empty uid.

  3. Creates insert, never upsert. The database layer grows Insert (ON CONFLICT (uid) DO NOTHING, zero rows affected β†’ a wrapped database.ErrAlreadyExists) beside the upserting Write, and every create seam (svc.CreateWithUID, svc.InsertTx) now goes through it. svc.MapDBError maps the sentinel to CodeAlreadyExists.

  4. Replay semantics, for every queue implementation. On replay: AlreadyExists on a create means the first send committed β€” success, drop the op. NotFound on a delete or un-set means the same β€” success. Domain rejections (InvalidArgument, PermissionDenied, FailedPrecondition, NotFound on an update target) drop the op and record it for the user's sync surface. Network-shaped failures stall the whole queue for a retry β€” strict FIFO, because skipping around a stalled op reorders history.

  5. Conflict ruling: minimal diff, then last write wins. A queued edit stores only the fields the user changed and replays only those; Update handlers already nil-check per field, so cross-field edits made by different members merge naturally. When two members change the same field of the same entity, the later replay wins silently. No version gating and no conflict-review UI: at household scale the same-field race is rare, and the mutation trail plus audit log already record what happened for anyone who asks. This is also exactly how Android has always behaved.

  6. Auth and admin mutations are excluded from offline queueing. They fail clean, as today. Reward claims queue, but their idempotency comes from the claim's own domain verdicts, not from this ADR's uid mechanism (the SPEND ledger row minted inside the claim transaction stays server-minted).

Consequences

Positive

  • A queue can mint the uid at enqueue time and treat AlreadyExists as confirmation, which makes create replay exactly as safe as set replay.
  • Queued ops that reference an offline-created entity carry its real uid from the start β€” Android's local- swap-and-remap machinery becomes unnecessary and can be retired when its queue adopts this.
  • Create-never-overwrites is now structural for every caller, including MCP and the CLI: a colliding create is a named verdict, not a silent clobber of the mutation trail.

Negative / trade-offs

  • A client can choose entity uids. The v7-only gate keeps time-ordering, and uids carry no authority anywhere (authorization is role- and resource-guard-based), so the surface is the uid value itself β€” accepted.
  • A malicious authenticated client could pre-mint a uid it expects another flow to use. Uids are 122 bits of randomness plus a timestamp; collision requires guessing, and the worst outcome is a failed create. Accepted.
  • PushIngredients creates items internally with server-minted uids, so a replayed push still duplicates shopping-list lines. Accepted for now: the window is one lost response, the damage is visible and trivially deleted, and the fix (per-line client uids) can ride a later change if it ever matters. SetOverride (upsert by date) and SplitOccurrence/ CancelOccurrence (idempotent by slot lookup) already converge.

Alternatives considered

  • Generic Idempotency-Key header with a server-side dedup table. Covers every RPC uniformly, but needs a new table, a TTL story and an interceptor, and only creates actually need help β€” the set-shaped mutations already converge. Heavier machinery for the same outcome.
  • Accept duplicates, let the user delete them. A reconnect after a flaky window can replay a burst; spraying duplicates at a family board fails the failure-honesty bar.
  • Server-side create dedup by content hash. Two genuinely identical creates (two "Milk" items) are legitimate; content identity is not request identity.
  • Version-gated conflicts instead of LWW. Needs a base-version on every mutating RPC and a review UI per entity type; enterprise machinery with no household-scale problem to solve.