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:
| household | created | chores | activities |
|---|---|---|---|
8d1aa886 | 2026-07-03 20:10 (newest) | 3 | 0 |
58f8a997 | 2026-07-03 19:56 | 3 | 0 |
985566a0 | 2026-06-27 (oldest) | 7 | 2 |
Two independent defects produced this:
- Non-idempotent bootstrap.
HouseholdService.createHouseholdalways 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 commit7bcaf2a) minted another household instead of reattaching. - Non-deterministic
getHousehold(). It usedselectMaybeSingle('households', {})— unfiltered, noORDER BY,limit 1— so it returned an arbitrary RLS-visible household. But the UI resolves the current member/roster/chores viamemberByAuthUserId→ newest-first. Reads and writes could target different households, so a created activity "disappeared" into a household the UI wasn't showing.
Decision
-
getHousehold()must matchmemberForAccount. Household resolution for writes resolves the newest account-linked member's household (newest-first bycreated_at) — the same orderingmemberByAuthUserId/bootstrapSessionuse — so writes and reads target the same household. Implemented at the adapter tier across all threeStoragePorts (in-memory, Drift, Supabase); the write-through cache inherits it. -
Bootstrap is idempotent. On the account-first path (
creatorAuthUserId != null),createHouseholdchecksmemberByAuthUserId(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 viaacceptInviteis preserved — only the bootstrap auto-create is guarded. -
auth_user_idstays nullable + non-unique. The schema remains natively multi-household (per the role-governance ADR); this fix does not add a unique constraint onauth_user_id. -
The owner-scoped partial-unique index is flagged, not applied. A DB backstop was considered:
CREATE UNIQUE INDEX one_owned_household_per_accountON 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)
| Slice | Commit | What |
|---|---|---|
| D | da4adbe | (adjacent) SDK↔RLS catalog authz gate — surfaced the duplication as the more likely Dad-facing symptom. |
| D′ | 55ac14e | Deterministic 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'smemberForAccount. Still deterministic and correct for the single-adult and duplicate-bootstrap cases. The robust form threads the session uid into resolution (or routes_requireHouseholdthroughbootstrapSession). - 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-levelloading/loaded/emptytri-state on the identity read — deferred hardening.