Role governance — account-linked roles, member-default invites, switcher opt-in (design)
Owner decisions captured 2026-07-04 (ledger: .superpowers/sdd/progress.md
"ROLE-GOVERNANCE DECISION" + addenda). Status: approved direction, spec for review.
Goal
Roles beyond member require a real, linked user account. Invites — whether creating a
new co-parent placeholder or attaching an account to an existing member profile — always
land as member; an admin promotes them afterward in user management. Helpers gain
account-switcher access only via an explicit per-member opt-in flag.
Identity model (binding vocabulary)
Three distinct concepts — implementers must not conflate them:
| Concept | What it is | Where it lives | Scope |
|---|---|---|---|
| User | The auth account — one per human | Supabase auth.users (NOT a domain table) | Global, household-agnostic |
| Member | A household profile (name, kind, roles, wallet, …) | household_members row | ONE household (household_id-scoped) |
| Link | member → user | household_members.auth_user_id — nullable FK, deliberately NON-unique (20260612000001_households.sql:16-23) | One user may link to member rows in MULTIPLE households |
Consequences:
- The schema and RLS are natively multi-household: the
*_household_ids()SECURITY DEFINER helpers already return the PLURAL set of households forauth.uid(). Only the app bootstrap (memberForAccount, newest-first) and the UX are single-household — per owner decision 7, that stays; do NOT add a unique constraint onauth_user_id, ever. - An invite's purpose is to establish the link. The invitee creates a user (or signs into their existing one), then acceptance links user ↔ member.
- When the member already exists (James/River case): the invite rides on the existing member row (§3); acceptance just links.
- When the member doesn't exist (co-parent case): the invited PLACEHOLDER member
(status
invited, created at invite time) is the implementation of "create the member during account linking" — the row is the reserved seat, visible in the roster as pending with resend/revoke, and only becomes a real linked member at acceptance. Deferred row creation (a separate invites table) was considered and rejected: same outcome, loses pending-roster visibility, adds a model.
Owner decisions (binding)
- Linked-account role gate: admin/helper can only be granted to a member with
authUserId != null. Unlinked profiles (e.g., James, River) show roles locked to member with an "invite first" hint. - Invites default to
{member}— reverses G-7's{admin}-pre-granted co-parent placeholder and its "full admin once they accept" copy. - Promotion is a separate admin action in user management, only after acceptance/link.
- Invite scope: BOTH — (a) the existing new-co-parent placeholder invite and (b) a new "invite to account" on existing adult member profiles.
- Kid accounts: architecturally allowed later, NOT in MVP-1 — invite affordance is adult-only (coParent/otherAdult kinds); child accounts wait on the COPPA HARD gates (launch checklist). Nothing in this design may preclude child accounts later.
- Accept flow handles both account states: no account → sign up, then link; existing account → accept the invite (link existing auth account) — no second sign-up.
- Multi-household accounts: out of scope. Newest-first
memberForAccountstands; no household picker. - Helper switcher opt-in (same slice): switcher identity gate becomes admin ∪ owner ∪ (helper WITH per-member flag). Helper loses automatic access.
Current state (verified 2026-07-04, HEAD ≥ 3c56e09)
| Piece | Location | Finding |
|---|---|---|
| Member↔account link | packages/client_sdk/lib/src/models/household_member.dart:88 | authUserId String? exists; invite fields (inviteToken, expiry, email) exist. |
setRole | facade client.dart:159; household_service.dart (both entry points) | Kind guards only (admin⇒isParental, helper⇒isAdult). No linkage guard. |
inviteCoParent | facade client.dart:283 | Creates coParent + {admin} + MemberStatus.invited placeholder w/ token+expiry. Contradicts decision 2. |
acceptInvite | facade client.dart:299 | Exists: token → active, links authUserId, clears token. No app UI consumes it (G-7 deferred email/deep-link infra). |
linkAccountToMember | facade client.dart:312 | Exists (SP3 §6 shadow-profile claim). |
resendInvite / revokeInvite | facade client.dart:291,295 | Exist, wired to placeholder rows (G-7 UI). |
| Switcher gate | selected_member_repository.dart:163 _canOfferHouseholdIdentitySwitcher | viewHouseholdAll ∥ owner → today helper has automatic access. |
| Role editor | governance (M1 multi-select) | Offers admin/helper on any member passing kind guards — no linkage check. |
| Setup routing | authenticated_guard.dart | Confirmed no-member → SetupRoute (P1 fix). Natural home for "Join with invite code". |
Design
1. Linkage guard (service + UI)
HouseholdService.setRole (both entry points, beside the kind guards): granting a role
set containing admin or helper throws DomainRuleException when the target member's
authUserId == null (matches the sibling _requireRoleKind guard's exception type — a
domain rule, not input validation). Bootstrap (account-first first parent) is unaffected
— that member is linked from birth. Pending placeholders have no authUserId until
acceptance, so "can't promote until accepted" falls out of the same check.
Implemented in RG-S1 as _requireLinkedAccount
(packages/client_sdk/lib/src/services/household_service.dart:707-731).
Role editor: admin/helper controls disabled (not hidden) for unlinked members, with hint copy — "Invite ⟨name⟩ to a user account to enable roles." Kind guards still stack on top (child never gets roles regardless of account).
2. Invite defaults
inviteCoParent placeholder roles: {admin} → {member}. UI copy: "joins as a member —
you can promote them after they accept." resendInvite/revokeInvite unchanged.
3. Invite an existing adult member to an account
New verb inviteMember({actingMemberId, memberId, email, note?, validFor})
(facade → HouseholdService → adapters):
- Target must be an adult kind (coParent/otherAdult) — child target throws
ValidationExceptionin MVP-1 (COPPA deferral; the check is a kind test so lifting it later is one line + consent wiring). - Target must be unlinked (
authUserId == null) and not already invited. - Stamps
inviteToken/expiry/email on the EXISTING row; status staysactive(the member keeps functioning in the household — unlike placeholder invites, nothing is disabled). Roles untouched (they're member-only by the linkage guard anyway). acceptInvite(existing verb) handles acceptance identically for both invite shapes: linksauthUserId, clears token; "transitions to active" is a no-op for already-active rows (verify + test).
4. Acceptance UX without email infra (MVP mechanism)
No email delivery exists yet (G-7 deferral stands). MVP acceptance = out-of-band invite code:
- Inviting (either shape) surfaces a shareable code (the token) with a copy affordance; resend regenerates it.
- The SetupPage (where confirmed no-member users land) gains a "Have an invite
code?" entry: sign in/up first (auth account exists either way), enter code →
acceptInvite(token, authUserId)→ bootstrap re-resolves into the household (the P1 memo already clears on no-member, so re-resolve works). - Existing-account holders: same path — sign in, land on Setup (no member yet), enter code.
- Email/deep-link delivery remains a follow-up; this design is forward-compatible (a deep link just prefills the code).
5. Helper switcher opt-in flag
- New additive member field
canSwitchAccounts(bool, default false) — model + json + all adapters (in-memory, cached, Drift bump, cloud) + seeds + one additive migration. - Switcher predicate (
_canOfferHouseholdIdentitySwitcher) becomes:roles.contains(admin) ∥ owner ∥ (roles.contains(helper) && canSwitchAccounts).viewHouseholdAllis NOT removed from helpers — it keeps gating other oversight surfaces (H-5 approvals visibility). - Editor toggle "Allow account switching," visible only on linked helpers, persisted via the existing member-update path (already behind the admin gate). Presentation-only: the switcher can only hide identities, never grant capabilities — no RLS change.
6. Postgres guard-trigger twin (dual gate)
After the invite default changes (which removes the only legitimate
admin-with-null-auth rows), add a guard trigger on members: reject role arrays
containing admin/helper when auth_user_id is null. Preconditions:
- Data audit of live rows first.
- Demote the pending spouse placeholder from
{admin}to{member}(one UPDATE, owner-approved; promote after she accepts).
Migration is additive (new trigger) + one data fix — safe ahead of deploy.
Out of scope
- Child/kid user accounts (post-MVP-1, COPPA-gated — design must not preclude).
- Multi-household accounts (newest-first stands).
- Email delivery / deep-link infra (code-entry accept is the MVP; delivery is additive later).
- Any change to
viewHouseholdAllsemantics or the viewingAs presentation-only invariant.
Error model
setRolew/ admin|helper on unlinked target →DomainRuleException(editor prevents; guard is the backstop). Implemented + reviewed in RG-S1.inviteMemberon child / linked / already-invited target →ValidationExceptionwith distinct messages.acceptInviteunknown/expired token → existing failure surface; SetupPage shows friendly copy + retry.
Testing (mandatory)
- SDK: setRole linkage-guard matrix (unlinked admin ✗ / unlinked helper ✗ / unlinked
member-only ✓ / linked admin ✓); inviteCoParent creates
{member}; inviteMember kind- linkage + already-invited guards; acceptInvite on active-row invite (existing-member shape) links + preserves status; promote-after-accept e2e.
- App: editor role controls disabled + hint for unlinked member; switcher matrix (admin ✓ / owner ✓ / helper-unflagged self-only / helper-flagged ✓ / member self-only); helper toggle visibility (linked helpers only); SetupPage invite-code flow test (code → member of household, wizard not shown).
- Postgres: trigger rejects admin-with-null-auth insert/update (RLS-parity style test).
- Suite counts never drop (app 406+ / SDK 864+ baseline).