Skip to main content

Household resolution determinism + idempotent onboarding bootstrap

Date: 2026-07-11 Status: Accepted — code fix shipped; a schema-level backstop is flagged, not applied. Related: role-governance ADR, account-switcher ADR, authored Dynamics page §4.

Context

The owner reported "activity creation is broken." The live DB (bgedvvmihygwxhjxlvfu) held one account owning three "Ellenberger" households:

householdcreatedchoresactivities
8d1aa8862026-07-03 20:10 (newest)30
58f8a9972026-07-03 19:5630
985566a02026-06-27 (oldest)72

Two independent defects produced this:

  1. Non-idempotent bootstrap. HouseholdService.createHousehold always inserted a new household — with no "does this account already have one?" check. Any re-onboarding (a fresh session, a double-submit, or a guard that wrongly routed an existing account to Setup — see the cold-read guard fix and commit 7bcaf2a) minted another household instead of reattaching.
  2. Non-deterministic getHousehold(). It used selectMaybeSingle('households', {})unfiltered, no ORDER BY, limit 1 — so it returned an arbitrary RLS-visible household. But the UI resolves the current member/roster/chores via memberByAuthUserIdnewest-first. Reads and writes could target different households, so a created activity "disappeared" into a household the UI wasn't showing.

Decision

  1. getHousehold() must match memberForAccount. Household resolution for writes resolves the newest account-linked member's household (newest-first by created_at) — the same ordering memberByAuthUserId / bootstrapSession use — so writes and reads target the same household. Implemented at the adapter tier across all three StoragePorts (in-memory, Drift, Supabase); the write-through cache inherits it.

  2. Bootstrap is idempotent. On the account-first path (creatorAuthUserId != null), createHousehold checks memberByAuthUserId(creatorAuthUserId) before inserting; if the account already has a member it reuses that household. A transport error propagates (unknown ≠ no-member) so a blip never mints a duplicate. Multi-household membership via acceptInvite is preserved — only the bootstrap auto-create is guarded.

  3. auth_user_id stays nullable + non-unique. The schema remains natively multi-household (per the role-governance ADR); this fix does not add a unique constraint on auth_user_id.

  4. The owner-scoped partial-unique index is flagged, not applied. A DB backstop was considered:

    CREATE UNIQUE INDEX one_owned_household_per_account
    ON household_members (auth_user_id)
    WHERE owner = true AND auth_user_id IS NOT NULL;

    It is not written because ownership can be granted/transferred (grantOwner/transferOwnership), so an account can legitimately own more than one household and this index would reject that. Decision pending — the controller decides whether it is acceptable given the ownership-transfer flows.

Implementation (shipped)

SliceCommitWhat
Dda4adbe(adjacent) SDK↔RLS catalog authz gate — surfaced the duplication as the more likely Dad-facing symptom.
D′55ac14eDeterministic getHousehold() (3 adapters + cache) + idempotent createHousehold bootstrap; RED→GREEN tests across in-memory/cloud/service.

Live DB duplicate-household cleanup (3 → 1) is handled by the controller separately; this ADR covers only the code that stops it recurring.

Residual / deferred (dashed)

  • Context-free getHousehold() cannot know the current auth uid, so in a rare multi-adult / invited-into-a-newer-household topology "newest account-linked member" can differ from a specific viewer's memberForAccount. Still deterministic and correct for the single-adult and duplicate-bootstrap cases. The robust form threads the session uid into resolution (or routes _requireHousehold through bootstrapSession).
  • The app-side trigger (guard bouncing an onboarded account to Setup on a cold read) is fixed by the tri-state guard + SetupBounceLatch (7bcaf2a, c9c2c28); fully preventing the initial flash needs an SDK-level loading/loaded/empty tri-state on the identity read — deferred hardening.