SP‑A — Multi-Household Identity Foundation — Design Spec
Date: 2026-07-22
Status: Draft for owner review
Initiative: Onboarding + invite + account-management + multi-household (sub-project A of 4). Vision: docs/decisions/2026-07-22-onboarding-invite-account-management-vision.md. Decomposition: SP‑A (this doc, foundation) → SP‑B (code-first onboarding + invite binding) → SP‑C (delete-and-merge) → SP‑D (account/user-management admin).
Branch target: feat/mvp1-personas-authz (or a fresh feature branch).
Goal
Let one auth account belong to multiple households, with an explicit, server-persisted active household and a minimal switcher, so the join flow (SP‑B), merge (SP‑C), and account-management admin (SP‑D) have a foundation to build on. SP‑A is independently testable via create household A → create household B → switch between them — no dependency on the invite flow.
What already works (no change needed)
A read-only schema audit (2026-07-22) found the data model is already multi-household-capable by construction:
household_membersIS the join — it carries bothauth_user_id(account link, nullable + non-unique) andhousehold_id. An account can already hold member rows in multiple households.- RLS already scopes to ALL of an account's households. Every domain policy is
household_id in (select public.member_household_ids()), andmember_household_ids()returnssetof uuid(select household_id from household_members where auth_user_id = auth.uid()). A second membership row automatically widens access. Zero RLS rewrites. - Invite RPCs are already per-household.
accept_invite/link_childguard only "already a member of this household" — they do not block belonging to another. No change for SP‑A.
The single-household assumptions to replace are only three: (1) getHousehold() picks the newest-joined household by heuristic; (2) createHousehold's idempotency guard reuses any existing household (blocking a 2nd); (3) resolution has no notion of an explicit "active" household.
Locked decisions (owner, 2026-07-22)
- Identity model = per-household member rows; shared cross-household identity DEFERRED. Each membership is its own
household_membersrow (own ledger/traits/consent/roles). A person in two households has two independent profiles for now. The "same human, continuous identity across households" (theidentity_id/ divorce-clone concept) is a large migration touching everymember_idFK — explicitly out of SP‑A, revisited post-MVP. - Active household = server-side, per account. A new table keyed by the auth account, read on session bootstrap. Cross-device consistent. (Not client-only; not the hybrid client-cache.)
- Switch mechanism = re-resolve with a single client. Keep the one SDK client; make
getHousehold()active-household-aware; on switch, persist the new active household server-side and re-bootstrap the app (re-read household + roster, re-scope every bloc) — the same path a fresh sign-in takes. NohouseholdIdthreading through the SDK; no per-household client instances.
Architecture — the change surface
1. Data: account_active_household table
New migration infra/supabase/migrations/<ts>_account_active_household.sql:
create table public.account_active_household (
auth_user_id uuid primary key references auth.users (id) on delete cascade,
active_household_id uuid not null references public.households (id) on delete cascade,
updated_at timestamptz not null default now()
);
alter table public.account_active_household enable row level security;
-- An account may read/insert/update ONLY its own active-household row.
create policy account_active_household_select on public.account_active_household
for select to authenticated using (auth_user_id = auth.uid());
create policy account_active_household_upsert on public.account_active_household
for insert to authenticated with check (auth_user_id = auth.uid());
create policy account_active_household_update on public.account_active_household
for update to authenticated using (auth_user_id = auth.uid())
with check (auth_user_id = auth.uid());
Notes:
on delete cascadeonactive_household_id: if the active household is deleted (SP‑C merge, or a household delete), the row disappears and resolution falls back to the newest-joined heuristic — a safe default (see error handling).- Integrity: the active household should be one the account is a member of. Rather than a cross-table CHECK (not expressible in a simple FK), the
setActiveHouseholdservice validates membership before upserting (belt), andgetHousehold()re-validates on read (suspenders — if the active row points at a household the account is no longer in, ignore it and fall back). This keeps the table simple and the invariant enforced in the service layer, consistent with the codebase's "enforce in service AND schema where practical" rule.
2. SDK (packages/client_sdk)
getHousehold()becomes active-household-aware (supabase_households.dart+ the household service): readaccount_active_household.active_household_idforauth.uid(); if set and the account has a member row in it → return that household; otherwise fall back to the existing newest-joined-member heuristic (full back-compat when no active row exists). This is the ONLY resolution change; every service method that calls_requireHousehold()/getHousehold()then transparently operates on the active household.setActiveHousehold(String householdId)(household service + adapter): validate the caller is a member ofhouseholdId(viamember_household_ids()/ a membership read); upsertaccount_active_household. Throw a typedDomainRuleExceptionif not a member.listMyHouseholds()(household service + adapter): return the account's households —select households.* from households join household_members on ... where auth_user_id = auth.uid(), with a flag for which is active. Powers the switcher.createHouseholdguard narrowed (household_service.dart): today it returns the existing household if the account has any member row (blocking a 2nd household). Narrow it: allow creating a new household when the account already has memberships; keep only genuine double-submit idempotency (e.g. keyed on an in-flight create), not "any existing membership." On create, set the new household as active.bootstrapSessionalready fetches the resolved household; with the change above it resolves the active household. No separate call needed.
3. App (app/)
ActiveHouseholdRepository(thin delegate,app/lib/outside/repositories/...):Future<List<HouseholdSummary>> listMyHouseholds(),Future<void> switchTo(String householdId), and a way to observe the current active household (reuseHouseholdRepository's household stream, which now reflects the active one).- Re-scope on switch:
switchTocallssetActiveHouseholdthen triggers a re-bootstrap — the app re-runs the same household-resolution path a fresh sign-in uses (AuthenticatedGuard→getHousehold()→ shell), so the household + roster repositories and every dependent bloc re-read against the new active household. Implementation: emit anActiveHouseholdChangedsignal the shell listens to (a soft re-login without re-auth); prefer reusing the existing bootstrap wiring over bespoke per-bloc invalidation. - Member-switcher interaction: switching household resets the within-household "viewing-as" lens —
SelectedMemberRepositoryclears, andCurrentMemberRepositoryre-resolves the authed member against the new household's roster. (The two switchers are distinct: outer = which household; inner = which member you're viewing-as inside it.) - Minimal switcher UI (owner-confirmed 2026-07-22): the active household's name, rendered as a tappable element at the TOP of the More page (not a buried settings row, and not the app bar — this keeps it prominent/discoverable without consuming app-bar space or competing with the member "viewing-as" control). Tapping it opens a picker of the account's households (from
listMyHouseholds, the active one marked) with tap-to-switch, plus a "+ Create another household" entry that routes into the existing Setup/create-household wizard (now permitted by the guard fix — owner-confirmed to reuse it rather than build a lighter flow in SP‑A). Placement rationale: the More tab is the account/settings surface, and SP‑D expands exactly here into full account/user-management (SP‑D may add secondary entry points, e.g. an app-bar affordance, without moving this one).
Error handling
- Active row points at a household the account left/lost:
getHousehold()ignores it and falls back to newest-joined;setActiveHouseholdfor a non-member household throwsDomainRuleException(and RLS/service block it). - Active household deleted:
on delete cascaderemoves the row → fallback to newest-joined. (SP‑C's merge will explicitly re-point active before deleting.) - No memberships at all (brand-new account):
getHousehold()returns null → existing Setup onboarding, unchanged. - Switch fails mid-flight (network): surface a typed error via the repository → a themed dialog (the codebase's
on <SpecificException>convention; never a barecatch, never catchError); the active household is unchanged until the server upsert succeeds.
Testing
- SDK unit:
getHouseholdactive-aware (active set + member → returns it; active unset → newest-joined; active set to a non-member household → fallback);setActiveHousehold(member → upserts; non-member → throws);listMyHouseholds(returns all memberships, active flagged);createHouseholdwhen the account already has a membership → creates a 2nd and sets it active. - RLS (two-identity):
account_active_householdis self-scoped — account X cannot read or set account Y's active household; cross-household data isolation preserved when an account belongs to two households (each household's rows visible, no leakage between them beyond the account's own memberships). - App/flow: create household A → create household B → switch A↔B re-scopes Today/roster (assert the shell shows B's data after switching); the member "viewing-as" lens resets to the authed member on switch; the "+ Create another household" path works.
- Reuse the existing SDK test doubles (
MockClient, in-memory adapter) + the flow-test harness. Baseline suites must not drop.
Scope boundary
In SP‑A: the account_active_household table + migration; active-household-aware getHousehold; setActiveHousehold + listMyHouseholds; the createHousehold guard fix; ActiveHouseholdRepository + re-scope-on-switch; the minimal More-tab switcher + create-another-household; member-lens reset on switch; tests.
NOT in SP‑A (later sub-projects):
- SP‑B: the join-a-household flow (code-first onboarding, invite-code redemption routing, share-code↔member binding). SP‑A only enables creating additional households, which is enough to exercise switching.
- SP‑C: delete-and-merge (consolidating households). SP‑A's
on delete cascadeon the active row is the seam SP‑C builds on. - SP‑D: the polished account/user-management surface (managing memberships, roles/consents, invites, and a refined switcher). SP‑A's More-tab entry is where SP‑D expands.
- Post-MVP: shared cross-household identity (
identity_id/ continuous kid identity / divorce-clone). - Unaffected: the COPPA/VPC child-signup gate — SP‑A adds no child signup path.
Global constraints
- One data path: Bloc → Repository → Client facade → Service → Adapter; presentation never imports drift/supabase.
- Invariants enforced in service AND schema where practical (active-household membership validated in the service; RLS self-scopes the table).
- Migrations are file-only under
infra/supabase/migrations/; apply to prod (bgedvvmihygwxhjxlvfu) via the Supabase MCP only when explicitly authorized (deploy-gated). - FVM only; no brand strings in package/class/table names; all copy via
Strings. - Error handling: typed
on <SpecificException>clauses, never barecatch (e), never catchError.
Resolved (owner, 2026-07-22)
- Switcher placement — the active household name as a tappable element at the top of the More page (prominent but space-efficient), opening the picker. ✅
- Create-another entry — reuse the existing Setup/create-household wizard for "+ Create another household." ✅