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():
MemberProfileBloc._resolveActingMemberId—lib/inside/blocs/member_profile/bloc.dartRoleOwnerBloc._runGuarded—lib/inside/blocs/governance/role_owner_bloc.dartSelfProfileBloc._onSaved—lib/inside/blocs/self_profile/bloc.dartSetupBloc._onConsentCaptured—lib/inside/blocs/setup/bloc.dartTermsGateBloc._onAccepted—lib/inside/blocs/terms_gate/terms_gate_bloc.dartConsentPrompt.promptForConsent—lib/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-1and the bloc started/acting onchild-1(the viewing-as identity),MemberProfileBlocdispatches (MemberProfileFundsMoved,MemberProfileMoveUndone) are verified to callwallet.moveFunds(..., actingMemberId: 'parent-1')— never'child-1'(:158-238).RoleOwnerBloc ._runGuardedis verified the same way forsetRole(:261-306), plus a no-current-member case that assertsactionFailurewith no fallback to any other id (:308-343). These tests fail loudly the moment a future edit wiresSelectedMemberRepositoryinto 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"), orrepositories/all(the barrel that re-exportsSelectedMemberRepositorytransitively — "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(theadminandhelperroles) or the householdownerflag (presentation gate only — owner alone does NOT grantviewHouseholdAllin [Authorizer], but owners must still switch among their kids; an owner-without-admin is constructible viagrantOwner()) → the switcher lists the full assignable roster (HouseholdRepository.assignableMembers(), already watch-only-excluded) plus self, minusinvitedplaceholders (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 participants — HouseholdRepository.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 variant | tealAA hex | Ratio 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:
| Surface | G-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 profile | richer as-them views |
| Today | stays parent master today | tailored per selected member |
| Approvals | stays keyed to the authenticated viewer | hide unless the selected identity holds helper (a display predicate — can only hide, never grant) |
| More / admin badges | MoreBloc 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)
| Slice | Commit | Summary |
|---|---|---|
| S1 | adfa53c | SelectedMemberRepository + SelectedMemberStore substrate; wired inert at the composition root; no consumer yet |
| S2 | 263919b | AccountSwitcherChip + AccountSwitcherCubit; sheet UI; "Viewing as {name}" + back-to-me |
| S2 fix | 28fd4d1 | Review findings — sheet chrome, integration tests, overflow |
| S3 | f2518be | Catalog adopts the global scope; local picker + _resolveDefaultSelected removed; isParentalViewer stays on current |
| S4 | 5f34443 | Invariant-lock test (authz_invariant_test.dart); profile/wallet light-touch (viewing-as label → profile route); caption fix |
| S4 fix | c14391f | a11y Semantics/48px on the viewing-as label; nav flow test; static-guard vectors 2 & 3 (store import, barrel import) |
| S4 fix | e13a1d7 | tealAA 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.