Skip to main content

Account switcher — the "viewing as" presentation lens

Date: 2026-07-03 · Status: Decided · Epic: G-2 Branch: feat/mvp1-personas-authz

G-2 introduces a single, app-scoped, reactive, per-account-persisted "viewing as" identity — a header avatar switcher matching the Google-apps selected-account concept the owner asked for. This document records where the selection lives, the hard invariant that keeps it from ever becoming an authorization input, the persistence/reset semantics, the switcher's identity-list gate, one default-resolution nuance (watch-only viewers), the a11y decisions, and the explicit boundary with H-5 (deferred). The full implementer brief is .superpowers/sdd/g2-plan.md.


Decision: a NEW SelectedMemberRepository, kept separate from CurrentMemberRepository

CurrentMemberRepository (app/lib/outside/repositories/current_member/current_member_repository.dart) resolves the authenticated member — .current() (:39-44) and .watch() (:51-92), matching AuthUser.id against the roster via matchMember (:98-107). This is actingMember: the sole principal for every gated SDK call and the identity Postgres RLS keys to (auth_user_id = auth.uid(), infra/supabase/migrations/20260612000001_households.sql:37,49).

G-2 does not extend this repository. It adds a sibling, app/lib/outside/repositories/selected_member/selected_member_repository.dart, that resolves a separate id — "whose content am I browsing?" — with its own port (selected_member_store.dart) and its own composition-root wiring (app/lib/app/runner.dart:129-135, app/lib/outside/repositories/all.dart:53,93-95).

Why a second repository instead of a field on the first: conflating the view into the authz anchor is exactly the failure the invariant below forbids. Two seams make "did an authz site accidentally read the view?" a grep-able, testable property — a single import 'selected_member string never belongs in an authz file, and a repository merge would make that unstatable. The dartdoc on SelectedMemberRepository (lines 20-33) states the invariant inline at the seam itself, not only in this ADR.

SelectedMemberRepository exposes current (sync getter), watch() (reactive, seeded on listen), select(memberId), and clearToSelf() (selected_member_repository.dart:76-126). Its default-resolution rule (_resolveDefault, :226-236) mirrors the retired CatalogBloc ._resolveDefaultSelected rule byte-for-byte in intent: the authenticated member when they're a live roster member, else the first non-watch-only participant, else null — see the "watch-only viewer" nuance below for the one place the two rules provably diverge.


The invariant: viewing-as is presentation-only, never an actor

Statement: SelectedMemberRepository is a display filter. It is never passed to an SDK verb as actingMemberId, never fed to Authorizer .capabilitiesFor(...) as the principal, and never consulted by a route guard. CurrentMemberRepository.current() remains the sole actor at mutation time for every one of the six AUTHZ consumer sites g2-plan.md §1 enumerates — the exact sites the BUG-1 fix (.superpowers/sdd/bug1-report.md) taught the codebase to key to current():

  1. MemberProfileBloc._resolveActingMemberIdlib/inside/blocs/member_profile/bloc.dart
  2. RoleOwnerBloc._runGuardedlib/inside/blocs/governance/role_owner_bloc.dart
  3. SelfProfileBloc._onSavedlib/inside/blocs/self_profile/bloc.dart
  4. SetupBloc._onConsentCapturedlib/inside/blocs/setup/bloc.dart
  5. TermsGateBloc._onAcceptedlib/inside/blocs/terms_gate/terms_gate_bloc.dart
  6. ConsentPrompt.promptForConsentlib/inside/routes/authenticated/governance/consent_prompt.dart

What a parent viewing-as-a-child can do: exactly what the SDK already allows an authenticated parent to do on a child subject (the existing supervised on-behalf path) — no more, no less. Switching the view never adds or removes a capability.

Enforcement — two layers, both shipped in S4 (5f34443 + c14391f)

app/test/unit/authz_invariant_test.dart (10 tests: 4 behavioral + 6 static — the static group runs 3 import-vector assertions per site inside one test() each):

  • Layer 1 — behavioral (4 cases). With CurrentMemberRepository.current = parent-1 and the bloc started/acting on child-1 (the viewing-as identity), MemberProfileBloc dispatches (MemberProfileFundsMoved, MemberProfileMoveUndone) are verified to call wallet.moveFunds(..., actingMemberId: 'parent-1') — never 'child-1' (:158-238). RoleOwnerBloc ._runGuarded is verified the same way for setRole (:261-306), plus a no-current-member case that asserts actionFailure with no fallback to any other id (:308-343). These tests fail loudly the moment a future edit wires SelectedMemberRepository into either bloc as the actor.
  • Layer 2 — static import guard (6 tests, one per authz site, 3 vectors each). Each authz file's raw source is asserted to contain none of: selected_member_repository (direct import), selected_member_store (persistence-port import — added in the S4 a11y-fix pass, c14391f, as "Vector 2"), or repositories/all (the barrel that re-exports SelectedMemberRepository transitively — "Vector 3", same commit) (:346-420). The three-vector design closes the two indirect-import paths a reviewer found in S4 review (an authz file importing the store directly, or importing the barrel instead of a scoped repository import).

No SDK change, no schema/migration, no new Capability, no HouseholdMember field — G-2 touches app/ presentation only.


Per-account persistence + reset semantics

Key scheme (app/lib/outside/effect_providers/shared_prefs/effect.dart:37-42): viewing_as.<accountKey>, where accountKey is the signed-in AuthUser.id, or the sentinel SelectedMemberRepository.demoAccountKey = '__demo__' (selected_member_repository.dart:50) when nobody is signed in. Persistence is backed by SharedPrefsSelectedMemberStore (selected_member/selected_member_store.dart:35-53), which reuses the same SharedPreferences instance the app's SharedPrefsEffect already resolved at boot (app/lib/app/runner.dart:125-135) — one preference store, not two. Clearing writes '' rather than removing the key (the Effect surface is get/set-only by design); read() maps an empty stored value back to null.

Reset / account-switch semantics (selected_member_repository.dart:191-203, _onCurrentChanged): the repository subscribes to CurrentMemberRepository.watch(). Whenever the resolved authenticated member's key changes — sign-in, sign-out, or an account switch — the repository loads that account's own persisted selection from the store; it never carries the previous account's in-memory pick across the boundary. Concretely:

  • Sign-in seeds the freshly-signed-in account's persisted selection (or its default, if none was ever persisted).
  • Sign-out switches to the __demo__ key, which resolves to that key's default (self/first-participant) unless something was previously persisted under __demo__ — in practice "back to self."
  • Account switch (A → B) loads B's own persisted value, not A's in-flight pick; switching back to A restores A's.

All three are pinned by app/test/unit/selected_member_repository_test.dart ("a fresh repo seeds the persisted selection for that account", "account switch loads the OTHER account's persisted selection" :341-374, "sign-out clears the selection back to the default" :376-399).

Roster validation — never a dangling id: every resolve re-checks the selected id against the live roster (_inRoster, :224,238-239); a selection that leaves the household (removed member, or an id from a stale/foreign persisted value) falls back to the default instead of being emitted (selected_member_repository.dart:224; test "a selection removed from the roster falls back to the default" and "an id not in the roster falls back to default, never emitting it").


The switcher's identity-list gate: viewHouseholdAll

The switcher must not let a plain member even attempt to browse someone else's private data — a UI floor layered on top of the SDK/RLS floor that already denies the read. SelectedMemberRepository.viewableIdentities (:139-160, exercised via the async viewableMembers() :165-169) implements the gate:

static List<HouseholdMember> viewableIdentities({
required HouseholdMember? viewer,
required List<HouseholdMember> assignable,
Authorizer authorizer = const Authorizer(),
}) {
if (viewer == null) return const [];
if (!authorizer.can(viewer, Capability.viewHouseholdAll)) {
return [viewer];
}
// ...assignable ∪ self, invited placeholders excluded...
}
  • Holds viewHouseholdAll (the admin and helper roles) or the household owner flag (presentation gate only — owner alone does NOT grant viewHouseholdAll in [Authorizer], but owners must still switch among their kids; an owner-without-admin is constructible via grantOwner()) → the switcher lists the full assignable roster (HouseholdRepository.assignableMembers(), already watch-only-excluded) plus self, minus invited placeholders (no data yet).
  • Lacks it → self only. Viewing-as is a no-op for a plain member — the switcher has nothing to offer them, by design, not by accident.
  • viewer == null (signed out / unlinked auth) → an empty list.

This is a pure, synchronous, unit-tested function (selected_member_repository_test.dart:68-126) — it can only ever hide identities from the switcher; it never grants a capability the SDK/RLS wouldn't already allow.

AccountSwitcherCubit (app/lib/inside/blocs/account_switcher/cubit.dart) reuses this exact static method (:111-114) so the header chip's menu and the repository's own gate can never drift apart.


Default-resolution nuance: a watch-only viewer defaults to viewing SELF

The retired CatalogBloc._resolveDefaultSelected rule checked membership against participantsHouseholdRepository.assignableMembers(), which excludes watchOnly members (household_service.dart:873, app/lib/inside/blocs/catalog/bloc.dart:607-616 pre-S3). A watch-only authenticated viewer therefore never matched their own default under that rule — they were not in participants — and it fell through to the first other assignable participant.

SelectedMemberRepository._resolveDefault checks membership against the full roster instead (_inRoster, which reads _roster — the unfiltered HouseholdRepository.watchMembers() stream, :224,229-236,238-239): a watch-only current member IS in the full roster, so the default now resolves to self, not to some other household member's browse content. This is a deliberate divergence from the retired rule, made because the retired rule's behavior becomes user-visible for the first time once a global switcher exists: without it, a watch-only viewer's very first catalog/browse render would silently default to someone else's eligibility/wallet view — "post-switcher" because before G-2 there was no persistent "whose view is this" indicator to make that surprising.

Confirmed in code (not by a dedicated "current member is watch-only" unit test — see the S5 completion report for the exact test coverage gap): the _resolveDefault roster check is unconditional on watchOnly, while the _resolveDefault participant fallback loop (the second branch, used only when current is absent/not-in-roster) explicitly skips watchOnly rows (:232-234), matching the "watch-only members are skipped for the default" test (selected_member_repository_test.dart:168-183) and the "G2-S3 default parity" suite's watchOnly head skipped case (:474-504).

Watch-only as a browse subject is inert in the surfaces G-2 touches today because watch-only members are structurally excluded from assignableMembers() — nothing is ever assigned to them, so any catalog item carrying explicit assignees fails CatalogBloc._choreEligibility's assignment gate for them (catalog/bloc.dart:444-450, "assigned to others"). This is an eligibility-level gate, not a watchOnly-specific SDK/service check — MVP-1's ChoreService.claimBounty (chore_service.dart:240-271) has no watchOnly branch, so an unassigned bounty is not blocked at the SDK layer by watch-only status alone. Revisit in UAT if a real watch-only member with unassigned bounties becomes a live scenario (tracked as a finding in .superpowers/sdd/g2-s5-report.md).


A11y: semantic tap targets + WCAG AA teal ink

Two a11y properties were decided for AccountSwitcherChip (app/lib/inside/routes/authenticated/shared/account_switcher_chip.dart):

1 — 48 × 48 logical-pixel minimum tap targets (all three interactive zones). The avatar button and back-to-self button use SizedBox(width: 48, height: 48); the "Viewing as {name}" label uses ConstrainedBox(constraints: BoxConstraints(minHeight: 48, minWidth: 48)) inside a Flexible so the label collapses safely on narrow phones without shrinking below the touch floor. Every zone wraps a Semantics(button: true, excludeSemantics: true) with an explicit screen-reader label — "Viewing as {name}, tap to switch" / "Switch account" / "Back to me" (committed in c14391f).

2 — tealAA design-system token replacing tealDeep on the label (committed in e13a1d7). tealDeep in the light theme (#188488) sits at ~4.3:1 against the app surface — below the WCAG AA 4.5:1 floor for small text (12 px caption). tealAA corrects this with theme-specific values chosen to clear 4.5:1 on every surface:

Theme varianttealAA hexRatio on surface
Light#107476~5.3:1
Dark#7DD6D7~9.4:1
Focus Mode (Tier-3 theme variant, reserved/unbuilt)#A7E0E1~13.3:1

All three are pin-tested in packages/design_system/test/tokens_test.dart ("tealAA small teal ink clears AA on its own surface (all variants)", :118-140), which asserts greaterThanOrEqualTo(4.5) for each theme's tealAA against its own surface token. The third row is the design system's reserved "Focus Mode" color theme (ColorTokens.focus, color_tokens.dart:479-482) — it is not a keyboard-focus state. Keyboard-focus a11y for the switcher (visible focus ring, a keyboard-navigable and focus-trapped menu, selection-change announcement — the g2-plan §3.4 non-negotiables beyond tap targets and contrast) is not implemented or tested in G-2; it is carried as an open a11y debt for the H-phase pass.


The H-5 boundary — explicitly deferred, not decided here

G-2 is deliberately small: the switcher, the indicator, the substrate, and one consuming surface — Catalog (f2518be, "the one surface that already has the split and is explicitly named 'replaces the catalog-local picker'"). Per g2-plan.md §4 and §7:

SurfaceG-2 (this epic)H-5 (consumes the scope)
Switcher + indicator, substrate✅ shipped
Catalog eligibility/wallet✅ follows viewingAs; isParentalViewer stays on current
Member profile / wallet✅ light: switcher routes to the selected member's profilericher as-them views
Todaystays parent master todaytailored per selected member
Approvalsstays keyed to the authenticated viewerhide unless the selected identity holds helper (a display predicate — can only hide, never grant)
More / admin badgesMoreBloc stays on current (untouched by any G-2 commit — verified via git log adfa53c..e13a1d7 -- app/lib/inside/blocs/more/bloc.dart = no hits)possibly re-scope

The deferred design question (g2-plan.md §7, not a G-2 decision): does the global scope mean "pure viewing lens" (a parent sees their own full app, previewing a child's data) or "also-acts-as-subject" (a parent viewing-as-child sees a child-limited surface, e.g. approvals hidden)? G-2 answers this only for Catalog, and only by preserving existing semantics — the selected member remains the claim/redeem subject exactly as the retired local picker made it, so G-2 is a pure rewire, not a behavior change there. H-5 must settle the general question for Today/approvals; it is surfaced here as an inherited design-forward note, not resolved.

See also docs/decisions/2026-07-02-h-phase-enhancements.md for the H-5 scope.


Commits (chronological)

SliceCommitSummary
S1adfa53cSelectedMemberRepository + SelectedMemberStore substrate; wired inert at the composition root; no consumer yet
S2263919bAccountSwitcherChip + AccountSwitcherCubit; sheet UI; "Viewing as {name}" + back-to-me
S2 fix28fd4d1Review findings — sheet chrome, integration tests, overflow
S3f2518beCatalog adopts the global scope; local picker + _resolveDefaultSelected removed; isParentalViewer stays on current
S45f34443Invariant-lock test (authz_invariant_test.dart); profile/wallet light-touch (viewing-as label → profile route); caption fix
S4 fixc14391fa11y Semantics/48px on the viewing-as label; nav flow test; static-guard vectors 2 & 3 (store import, barrel import)
S4 fixe13a1d7tealAA design-system token (AA-verified teal-on-surface, ~5.3:1 light / ~9.4:1 dark) replacing tealDeep on the viewing-as label

Verification note

Every file:line citation above was re-read against the tip of feat/mvp1-personas-authz (e13a1d7) while writing this document, not copied from the brief. Where the brief's phrasing was stronger than what the current code demonstrates (the watch-only "dual gate" claim), this document narrows the claim to what is actually enforced and flags the gap rather than asserting it. See .superpowers/sdd/g2-s5-report.md for the full claim → evidence mapping.