Skip to main content

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:

ConceptWhat it isWhere it livesScope
UserThe auth account — one per humanSupabase auth.users (NOT a domain table)Global, household-agnostic
MemberA household profile (name, kind, roles, wallet, …)household_members rowONE household (household_id-scoped)
Linkmember → userhousehold_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 for auth.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 on auth_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)

  1. 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.
  2. Invites default to {member} — reverses G-7's {admin}-pre-granted co-parent placeholder and its "full admin once they accept" copy.
  3. Promotion is a separate admin action in user management, only after acceptance/link.
  4. Invite scope: BOTH — (a) the existing new-co-parent placeholder invite and (b) a new "invite to account" on existing adult member profiles.
  5. 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.
  6. 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.
  7. Multi-household accounts: out of scope. Newest-first memberForAccount stands; no household picker.
  8. 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)

PieceLocationFinding
Member↔account linkpackages/client_sdk/lib/src/models/household_member.dart:88authUserId String? exists; invite fields (inviteToken, expiry, email) exist.
setRolefacade client.dart:159; household_service.dart (both entry points)Kind guards only (admin⇒isParental, helper⇒isAdult). No linkage guard.
inviteCoParentfacade client.dart:283Creates coParent + {admin} + MemberStatus.invited placeholder w/ token+expiry. Contradicts decision 2.
acceptInvitefacade client.dart:299Exists: token → active, links authUserId, clears token. No app UI consumes it (G-7 deferred email/deep-link infra).
linkAccountToMemberfacade client.dart:312Exists (SP3 §6 shadow-profile claim).
resendInvite / revokeInvitefacade client.dart:291,295Exist, wired to placeholder rows (G-7 UI).
Switcher gateselected_member_repository.dart:163 _canOfferHouseholdIdentitySwitcherviewHouseholdAll ∥ owner → today helper has automatic access.
Role editorgovernance (M1 multi-select)Offers admin/helper on any member passing kind guards — no linkage check.
Setup routingauthenticated_guard.dartConfirmed 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 ValidationException in 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 stays active (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: links authUserId, 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). viewHouseholdAll is 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 viewHouseholdAll semantics or the viewingAs presentation-only invariant.

Error model

  • setRole w/ admin|helper on unlinked target → DomainRuleException (editor prevents; guard is the backstop). Implemented + reviewed in RG-S1.
  • inviteMember on child / linked / already-invited target → ValidationException with distinct messages.
  • acceptInvite unknown/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).