Skip to main content

SP‑A Multi-Household Foundation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let one auth account belong to multiple households, with an explicit server-persisted active household and a minimal switcher (a tappable household name atop the More page), so the account can create household A, create household B, and switch between them.

Architecture: The data model + RLS are already multi-household-capable (household_members is the join; member_household_ids() is set-returning). SP‑A adds one small account_active_household table, makes getHousehold() active-household-aware (reads that table, falls back to the newest-joined heuristic), adds setActiveHousehold/listMyHouseholds across the SDK's 5 layers, narrows the createHousehold idempotency guard so a 2nd household is allowed (auto-set active), and wires a More-tab switcher that re-bootstraps the app on switch (reusing the existing sign-in bootstrapSessiongetHouseholdById re-hydrate seam) and resets the member "viewing-as" lens.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), the client_sdk 5-layer stack (Client facade → ClientImpl → HouseholdService → StoragePort → {cloud supabase_households, in-memory, cached} adapters), flutter_bloc, auto_route, client_sdk_testing doubles, the flow-test harness, Supabase (prod bgedvvmihygwxhjxlvfu).

Global Constraints

  • FVM only: fvm flutter ... / fvm dart ... — never bare. App test cmds run from app/; SDK test cmds from packages/client_sdk/.
  • One data path: Bloc/Repository → Client facade → Service → StoragePort → adapter. Presentation never imports drift/supabase.
  • Enforce in service AND schema: active-household membership is validated in HouseholdService (a caller may only set/resolve a household they're a member of); RLS self-scopes the new table.
  • Per-household member rows; shared cross-household identity is OUT of scope (deferred post-MVP).
  • Migration is file-only. Write the SQL under infra/supabase/migrations/; applying it to prod is deploy-gated — do NOT apply via the Supabase MCP without explicit owner authorization. SDK/app tests use the in-memory adapter (no SQL), so the suite is green without the migration applied.
  • Error handling: typed on <SpecificException> clauses only — never bare catch (e), never catch Error.
  • Codegen: if a state/model gains @JsonSerializable fields, regen the specific .g.dart with a scoped --build-filter, then git status + restore any collateral generated file. (This plan avoids new serializable models — see Task 6.)
  • Baselines must not drop. Record the current SDK + app suite counts at Task 1 start and keep them at/above baseline. Run the FULL relevant suite at the end of any task that changes a shared interface (StoragePort, Client, HouseholdService) — a signature change there touches all adapters + call sites.
  • Brand-neutral names; all copy via Strings; no ! bang on nullable.

Resolved decisions (controller — do not re-litigate)

  1. listMyHouseholds(authUserId)Future<List<Household>> (full model; active flag derived by the caller comparing to the resolved active household — no new HouseholdSummary).
  2. Switch re-scope = write the active row, then re-bootstrap via bootstrapSession(authUserId) (its getHouseholdById re-hydrates the cached adapter). No rehydrate method on StoragePort.
  3. createHousehold auto-sets the new household active (calls upsertActiveHousehold after insert).
  4. getHousehold() becomes active-aware (reads getActiveHouseholdId); AuthenticatedGuard is unchanged.
  5. SetupPage gains isCreatingAdditional: bool = false (skips the invite-code CTA; completion relies on auto-active create).

File Structure

Create:

  • infra/supabase/migrations/20260723000000_account_active_household.sql — the table + RLS.
  • app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart — the picker sheet.
  • Tests: packages/client_sdk/test/... (adapter + service), app/test/flows/household_switch_test.dart.

Modify (exact files, from the grounding audit):

  • packages/client_sdk/lib/src/adapters/adapter.dartStoragePort: + getActiveHouseholdId, upsertActiveHousehold, listHouseholdsForAuthUser.
  • packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart_activeHouseholds map + 3 impls + active-aware getHousehold.
  • packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart — 3 impls + active-aware getHousehold.
  • packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart — 3 impls (delegate; upsertActiveHousehold re-hydrate not required — see Task 3).
  • packages/client_sdk/lib/src/services/household_service.dartsetActiveHousehold, listMyHouseholds, active-aware getHousehold, createHousehold auto-active.
  • packages/client_sdk/lib/src/client/client.dart + client_impl.dart — 2 new facade verbs + delegation.
  • app/lib/outside/repositories/household/household_repository.dart — passthroughs.
  • app/lib/inside/blocs/more/bloc.dart (+ state) — load listMyHouseholds; switch event.
  • app/lib/inside/routes/authenticated/more/widgets/household_header_block.dartonTap + chevron.
  • app/lib/inside/routes/authenticated/setup/page.dartisCreatingAdditional.
  • app/lib/inside/i18n/strings.dart — switcher copy.

Task 1: account_active_household migration (file-only)

Files: Create infra/supabase/migrations/20260723000000_account_active_household.sql

Interfaces: Produces the table public.account_active_household(auth_user_id uuid pk → auth.users, active_household_id uuid not null → households, updated_at) with self-scoped RLS. Consumed by Task 3's cloud adapter (deploy-gated); SDK/app tests use the in-memory adapter and do NOT need this applied.

  • Step 1: Write the migration (verbatim from the approved spec):
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;

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());
  • Step 2: Lint the SQL (no apply). Confirm it parses locally if a linter exists (e.g. sqlfluff if configured), else visual review against the sibling migration 20260612000001_households.sql for style parity (lowercase, public. schema, RLS enabled + policies). Do NOT apply to prod.

  • Step 3: Commit

git add infra/supabase/migrations/20260723000000_account_active_household.sql
git commit -m "feat(sp-a): account_active_household table + self-scoped RLS (file-only, deploy-gated)"

The migration apply to prod bgedvvmihygwxhjxlvfu is a SEPARATE, owner-authorized deploy step (record it in the ledger as pending). The rest of SP‑A is testable without it via the in-memory adapter.


Task 2: StoragePort contract + in-memory adapter

Files:

  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (StoragePort)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart
  • Test: packages/client_sdk/test/adapters/in_memory_active_household_test.dart (or extend the existing in-memory adapter test)

Interfaces:

  • Produces on StoragePort: Future<String?> getActiveHouseholdId(String authUserId); Future<void> upsertActiveHousehold({required String authUserId, required String householdId}); Future<List<Household>> listHouseholdsForAuthUser(String authUserId).

  • Consumed by Task 3 (other adapters implement the same contract), Task 4 (getHousehold active-aware), Task 5 (service).

  • Step 1: Write the failing test — in-memory adapter round-trips the active household + lists memberships:

// match the existing in-memory adapter test harness (imports, seed factories)
test('upsert + getActiveHouseholdId round-trips per auth user', () async {
final a = InMemoryStorageAdapter();
// seed: authUser 'u1' member of households 'h1' and 'h2' (use the adapter's seed path)
await a.upsertActiveHousehold(authUserId: 'u1', householdId: 'h2');
expect(await a.getActiveHouseholdId('u1'), 'h2');
expect(await a.getActiveHouseholdId('u2'), isNull);
});

test('listHouseholdsForAuthUser returns all memberships', () async {
final a = InMemoryStorageAdapter();
// seed u1 in h1 + h2
final list = await a.listHouseholdsForAuthUser('u1');
expect(list.map((h) => h.id), containsAll(<String>['h1', 'h2']));
});

Read in_memory_storage_adapter.dart first to match its real seed mechanism (how _members/_households get populated in tests) and the Household constructor.

  • Step 2: Run → FAIL (cd packages/client_sdk && fvm dart test test/adapters/in_memory_active_household_test.dart).

  • Step 3: Add the 3 abstract methods to StoragePort (adapter.dart), with doc comments mirroring the neighbours (e.g. near getHouseholdById/memberByAuthUserId):

/// The account's explicitly-selected active household id, or null if unset
/// (callers fall back to the newest-joined heuristic).
Future<String?> getActiveHouseholdId(String authUserId);

/// Persist [householdId] as [authUserId]'s active household (upsert).
Future<void> upsertActiveHousehold({
required String authUserId,
required String householdId,
});

/// Every household [authUserId] is a member of.
Future<List<Household>> listHouseholdsForAuthUser(String authUserId);
  • Step 4: Implement in the in-memory adapter: add final Map<String, String> _activeHouseholds = {};; implement the three methods (upsert sets the map; get reads it; list filters _members by authUserId → collect their _households). Match the file's existing async style.

  • Step 5: Run the test → PASS, then the full SDK suite (cd packages/client_sdk && fvm dart test) — adding abstract methods to StoragePort means EVERY adapter must implement them or the package won't compile; Tasks 3 implements the others, so at THIS task the cloud/cached adapters will not yet compile. To keep the package compiling within Task 2, add temporary UnimplementedError stubs for the 3 methods on the cloud + cached adapters (they get real impls in Task 3) — OR sequence Task 3 immediately and treat 2+3 as one compile unit. Chosen: add throw UnimplementedError() stubs in cloud + cached here (clearly commented // SP-A Task 3), so the in-memory tests pass and the package compiles; Task 3 replaces them.

  • Step 6: Commit

git add packages/client_sdk/lib/src/adapters/ packages/client_sdk/test/adapters/in_memory_active_household_test.dart
git commit -m "feat(sp-a): StoragePort active-household verbs + in-memory impl (cloud/cached stubbed)"

Task 3: Cloud + cached adapter impls

Files:

  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart
  • Modify: packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart
  • Test: the fake-PostgREST cloud-adapter test path if present (graphify query "fake postgrest cloud adapter test"); else guarded/documented.

Interfaces: Consumes the StoragePort contract (Task 2). Produces real cloud + cached impls of the 3 verbs (replacing the Task 2 stubs).

  • Step 1: Cloud adapter — replace the stubs:

    • getActiveHouseholdId: select active_household_id from account_active_household where auth_user_id = :uid (maybe-single) → the id or null.
    • upsertActiveHousehold: upsert account_active_household (on_conflict auth_user_id), set updated_at = now().
    • listHouseholdsForAuthUser: households joined to household_members where auth_user_id = :uidList<Household>. Use the adapter's existing db.selectEq/upsert helpers (mirror how memberByAuthUserId/getHouseholdById call them). RLS enforces self-scope server-side.
  • Step 2: Cached adapter — the cached adapter wraps another StoragePort. Implement the 3 verbs as pass-through delegation to the wrapped adapter (_inner.getActiveHouseholdId(...), etc.). Re-hydration on switch is handled at the app layer via bootstrapSession (Task 6), NOT inside upsertActiveHousehold — keep the port method a pure write. (Confirm the cached adapter's delegation pattern by reading how it forwards an existing method like getHouseholdById.)

  • Step 3: Tests — if a fake-PostgREST harness exists for the cloud adapter, add a round-trip test there. If not, add a focused test on the cached adapter's delegation (using a fake inner StoragePort) asserting the 3 verbs forward correctly. Run the SDK suite (cd packages/client_sdk && fvm dart test) — now green with no stubs.

  • Step 4: Commit

git add packages/client_sdk/lib/src/adapters/
git commit -m "feat(sp-a): cloud + cached adapter active-household impls"

Task 4: active-aware getHousehold

Files:

  • Modify: packages/client_sdk/lib/src/services/household_service.dart (getHousehold) AND/OR the adapters' getHousehold (see below)
  • Test: packages/client_sdk/test/services/get_household_active_test.dart

Interfaces: Consumes getActiveHouseholdId + getHouseholdById + membership. Produces: getHousehold() returns the active household when set and the account is a member; else the existing newest-joined fallback.

Decision (where the active-resolution lives): put it in the adapters' getHousehold (both cloud + in-memory already implement getHousehold with the newest-joined walk — that is the right seam, and the cached adapter delegates). Rationale: the service's getHousehold is a thin _storage.getHousehold() passthrough; keeping resolution in the adapter keeps one code path and lets the cached adapter's hydration continue to work. Each adapter's getHousehold: read getActiveHouseholdId(authUserId); if non-null AND the account has a member row in it → getHouseholdById(activeId); else the current newest-joined logic.

  • Step 1: Failing test (in-memory): seed u1 in h1 + h2; with no active set → getHousehold() returns the newest-joined (match current heuristic); after upsertActiveHousehold(u1, h1) → returns h1; set active to a household u1 is NOT in (h3) → falls back to newest-joined (never returns h3). Note: getHousehold() derives the auth user from the adapter's auth context — match how the in-memory adapter knows "the current auth user" today (read its getHousehold impl; if it takes no arg, thread the active lookup via the same auth id it already uses).

  • Step 2: Run → FAIL.

  • Step 3: Implement the active-aware branch in the in-memory + cloud getHousehold (cached delegates). Preserve the exact existing fallback (the "Bugfix D' newest-first" behaviour) for the unset/non-member cases.

  • Step 4: Run the test + full SDK suite → green. Existing getHousehold callers are unchanged (same signature; behaviour only differs when an active row exists).

  • Step 5: Commit

git add packages/client_sdk/lib/src/adapters/ packages/client_sdk/test/services/get_household_active_test.dart
git commit -m "feat(sp-a): getHousehold resolves the active household (fallback preserved)"

Task 5: HouseholdService verbs + facade + createHousehold auto-active

Files:

  • Modify: packages/client_sdk/lib/src/services/household_service.dart
  • Modify: packages/client_sdk/lib/src/client/client.dart + client_impl.dart
  • Test: packages/client_sdk/test/services/active_household_service_test.dart

Interfaces:

  • Produces on the Client facade: Future<List<Household>> listMyHouseholds(String authUserId); Future<void> setActiveHousehold({required String authUserId, required String householdId}).

  • createHousehold(...) now calls upsertActiveHousehold for the creator after insert (auto-active).

  • Step 1: Failing tests:

    • setActiveHousehold: member → calls _storage.upsertActiveHousehold; non-member → throws DomainRuleException (validate via listHouseholdsForAuthUser/membership before upserting).
    • listMyHouseholds: returns the account's households.
    • createHousehold: after creating, the creator's active household is the new one (assert getActiveHouseholdId(creator) == newHouseholdId).
  • Step 2: Run → FAIL.

  • Step 3: Implement in HouseholdService:

Future<List<Household>> listMyHouseholds(String authUserId) =>
_storage.listHouseholdsForAuthUser(authUserId);

Future<void> setActiveHousehold({
required String authUserId, required String householdId,
}) async {
final mine = await _storage.listHouseholdsForAuthUser(authUserId);
if (!mine.any((h) => h.id == householdId)) {
throw const DomainRuleException('Not a member of that household.');
}
await _storage.upsertActiveHousehold(authUserId: authUserId, householdId: householdId);
}

In createHousehold, after the member/household are created and creatorAuthUserId != null, call upsertActiveHousehold(authUserId: creatorAuthUserId, householdId: created.id). Narrow the existing idempotency guard: today it returns the existing household if the account has ANY member — change it so it only short-circuits a genuine in-flight duplicate (or drop the "reuse any" behaviour), allowing a 2nd household. (Read the current guard at household_service.dart and replace the "reuse any existing membership" branch; keep creation idempotent only against an exact double-submit if such a key exists, else remove the guard.)

  • Step 4: Facade + ClientImpl — add the 2 abstract declarations to client.dart (mirror getHousehold's doc style) and delegate in client_impl.dart to _householdService.

  • Step 5: Run tests + FULL SDK suite → green (facade signature additions compile across ClientImpl + MockClient in client_sdk_testing — if MockClient is hand-written, add the 2 methods there; if it's a mocktail mock, no change).

  • Step 6: Commit

git add packages/client_sdk/lib/src/ packages/client_sdk/test/services/active_household_service_test.dart
git commit -m "feat(sp-a): setActiveHousehold + listMyHouseholds + createHousehold auto-active"

Task 6: App HouseholdRepository passthroughs + member-lens reset seam

Files:

  • Modify: app/lib/outside/repositories/household/household_repository.dart
  • Test: app/test/unit/repositories/household_repository_active_test.dart

Interfaces:

  • Produces: HouseholdRepository.listMyHouseholds(String authUserId), .setActiveHousehold({authUserId, householdId}), and .switchActiveHousehold({authUserId, householdId}) — the last one does the full switch: setActiveHouseholdbootstrapSession(authUserId) (re-hydrate) → returns the new Household. The member-lens reset (SelectedMemberRepository.clearToSelf()) is invoked by the MoreBloc (Task 7), not here, so the repository stays a thin data delegate.

  • Step 1: Failing testlistMyHouseholds/setActiveHousehold forward to the client; switchActiveHousehold calls setActiveHousehold then bootstrapSession and returns the resolved household. Use a MockClient.

  • Step 2: Run → FAIL.

  • Step 3: Implement the thin passthroughs (mirror the existing HouseholdRepository delegate style — Future<X> foo(...) => _clientProvider.client.foo(...)):

Future<List<Household>> listMyHouseholds(String authUserId) =>
_clientProvider.client.listMyHouseholds(authUserId);

Future<void> setActiveHousehold({required String authUserId, required String householdId}) =>
_clientProvider.client.setActiveHousehold(authUserId: authUserId, householdId: householdId);

/// Full switch: persist active, then re-bootstrap (re-hydrates the cache to the
/// new household). Returns the newly-active household.
Future<Household?> switchActiveHousehold({
required String authUserId, required String householdId,
}) async {
await _clientProvider.client.setActiveHousehold(authUserId: authUserId, householdId: householdId);
return _clientProvider.client.bootstrapSession(authUserId);
}
  • Step 4: Run test + full app suite → green.

  • Step 5: Commit

git add app/lib/outside/repositories/household/household_repository.dart app/test/unit/repositories/household_repository_active_test.dart
git commit -m "feat(sp-a): HouseholdRepository active-household passthroughs + switch"

Task 7: More-tab switcher (tappable name + picker)

Files:

  • Modify: app/lib/inside/routes/authenticated/more/widgets/household_header_block.dart (tappable)
  • Create: app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart
  • Modify: app/lib/inside/blocs/more/bloc.dart (+ state), app/lib/inside/i18n/strings.dart
  • Test: app/test/flows/household_switch_test.dart

Interfaces: Consumes HouseholdRepository.listMyHouseholds/switchActiveHousehold, SelectedMemberRepository.clearToSelf, CurrentMemberRepository. Produces the switcher UI + MoreBloc switch handling.

  • Step 1: Add strings (householdSwitcherTitle, householdSwitcherCreateAnother, etc.).

  • Step 2: Make HouseholdHeaderBlock tappable — add final VoidCallback? onTap;; wrap the DsCard content with the tap (the block's doc comment already calls it "the future HOUSEHOLD SELECTOR seat") + a trailing Icon(Icons.expand_more). Preserve its current layout/keys.

  • Step 3: HouseholdSwitcherSheet — a modal listing listMyHouseholds (active one marked with a check, tap → switch), plus a "+ Create another household" row. Tapping a household dispatches a MoreHouseholdSwitchRequested(householdId) event; "create another" pushes SetupRoute(isCreatingAdditional: true) (Task 8).

  • Step 4: MoreBloc — load listMyHouseholds on MoreStarted (store in state for the sheet); handle MoreHouseholdSwitchRequested: resolve the authed authUserId (from CurrentMemberRepository/auth), call HouseholdRepository.switchActiveHousehold, then SelectedMemberRepository.clearToSelf(), then navigate to the shell root (router.replaceAll([MainShellRoute()])) so the whole app re-scopes. Guard errors with on <SpecificException> → a themed failure (never bare catch).

  • Step 5: Flow test — this pairs with Task 8's create-second-household; for Task 7 assert: with the account in two seeded households, tapping the header opens the sheet listing both (active marked), tapping the other switches (verify switchActiveHousehold called + the shell re-scopes to the other household's data). Use the flow-test harness + MockClient/in-memory stubs.

  • Step 6: Run flow test + full app suite → green. Commit.

git add app/lib/inside/routes/authenticated/more/ app/lib/inside/blocs/more/ app/lib/inside/i18n/strings.dart app/test/flows/household_switch_test.dart
git commit -m "feat(sp-a): More-tab household switcher (tappable name + picker)"

Task 8: Setup re-entry for "+ Create another household"

Files:

  • Modify: app/lib/inside/routes/authenticated/setup/page.dart (+ its bloc if the CTA logic lives there)
  • Modify: app/lib/inside/routes/router.dart + router.gr.dart (SetupRoute gains the param — scoped regen)
  • Test: extend app/test/flows/household_switch_test.dart

Interfaces: Consumes createHousehold (now auto-active, Task 5). Produces SetupPage({isCreatingAdditional = false}) that, when true, hides the invite-code CTA and on completion navigates to the shell (the created household is already active).

  • Step 1: Failing flow test — create household A (first-run Setup) → from the More switcher tap "+ Create another" → Setup opens in isCreatingAdditional mode (no invite CTA) → name household B → land on B's shell → open switcher → both A and B listed → switch back to A re-scopes. (One end-to-end flow proving the SP‑A goal.)

  • Step 2: Run → FAIL.

  • Step 3: Add isCreatingAdditional to SetupPage (@RoutePage param) — when true, hide the invite-code secondary CTA; the completion path already lands on the shell, and because createHousehold auto-sets the new household active (Task 5), the guard resolves B. Scoped router regen: cd app && fvm dart run build_runner build --build-filter "lib/inside/routes/router.gr.dart"git status restore collateral.

  • Step 4: Run the flow test + FULL app suite → green.

  • Step 5: Update graphify + commit

git add app/lib/inside/routes/ app/test/flows/household_switch_test.dart
git commit -m "feat(sp-a): Setup re-entry for create-another-household + end-to-end switch flow"
cd .. && graphify update . && git add graphify-out/ && git commit -m "chore: graphify update after SP-A" || true

Self-Review

1. Spec coverage (against docs/superpowers/specs/2026-07-22-multi-household-foundation-sp-a-design.md):

  • account_active_household table + RLS → Task 1. ✅
  • active-aware getHousehold → Task 4. ✅
  • setActiveHousehold + listMyHouseholds (5 layers) → Tasks 2/3/5. ✅
  • createHousehold guard narrowed + auto-active → Task 5. ✅
  • ActiveHouseholdRepository/switch + re-scope → Task 6 (folded into HouseholdRepository.switchActiveHousehold — the spec's "ActiveHouseholdRepository" is realized as methods on the existing thin HouseholdRepository rather than a new class, per YAGNI; note this deviation). ✅
  • tappable-name switcher + create-another (reuse Setup) → Tasks 7/8. ✅
  • member-lens reset on switch → Task 7 (clearToSelf). ✅

2. Placeholder scan: each code step shows the code or an exact modify-target; commands are FVM + scoped. The > Read X first notes are verify-against-codebase instructions (the two explorers gave file:line but the implementer confirms exact current signatures), not placeholders. ✅

3. Type consistency: getActiveHouseholdId/upsertActiveHousehold/listHouseholdsForAuthUser (port) → listMyHouseholds/setActiveHousehold (service+facade) → switchActiveHousehold (app repo) used consistently. authUserId param name uniform. List<Household> (no HouseholdSummary) throughout. ✅

Cross-cutting notes for the executor

  • Tasks 2+3 are a compile unit: Task 2 adds abstract StoragePort methods (breaks cloud/cached compile) and stubs them; Task 3 replaces the stubs. Do not leave UnimplementedError past Task 3. Run the SDK suite at the end of 3 (not just 2).
  • Signature-change discipline: Tasks 2 (StoragePort) and 5 (Client/HouseholdService) change shared interfaces → every adapter + MockClient must compile; run the full SDK suite at each. Task 8 changes a route → full app suite + scoped router regen.
  • Migration apply is deploy-gated — SP‑A ships + tests green WITHOUT applying it (in-memory adapter). Record "apply 20260723000000_account_active_household.sql to prod" as a pending owner-authorized step.
  • Deferred (not SP‑A): SP‑B join flow, SP‑C merge, SP‑D polished admin, shared cross-household identity.