Role governance — account-linked roles, member-default invites, switcher opt-in
Date: 2026-07-04 (finalized 2026-07-07)
Status: Accepted — RG-S1..S4 shipped; guard trigger (RG-S5) deferred (see below).
Spec: docs/superpowers/specs/2026-07-04-role-governance-account-linked-roles-design.md
Plan: docs/superpowers/plans/2026-07-04-role-governance-account-linked-roles.md
Context
Rewhaven separates three identities (binding vocabulary):
| Concept | What | Where | Scope |
|---|---|---|---|
| User | auth account, one per human | Supabase auth.users | global |
| Member | household profile (name, kind, roles, wallet…) | household_members row | one household |
| Link | member → user | household_members.auth_user_id — nullable, NON-unique | one user ↔ member rows in many households |
The link FK is deliberately non-unique and the RLS *_household_ids() helpers are
plural, so the schema is natively multi-household (the app is single-household by
current UX choice — newest-first memberForAccount; multi-household UX is out of scope).
Never add a unique constraint on auth_user_id.
Decisions
- Roles beyond
memberrequire a linked account. Admin/helper can only be held by a member withauth_user_id != null. Unlinked profiles (kids without accounts, shadow adults) are member-only until invited + linked. - Invites default to
{member}. Both invite shapes (new co-parent placeholder, existing-member "invite to account") land the target as member. Reverses G-7's{admin}-pre-granted co-parent placeholder. - Promotion is a separate admin action in user management, after link. You invite → they create/sign-into a user → accept (link) → an admin then promotes.
- Two invite shapes, one acceptance. New co-parent →
invitedplaceholder member (reserved seat, deleted on revoke). Existing adult member →inviteMemberstamps a token on the live row, status staysactive(member keeps functioning), and revoke clears the invite fields, never deletes the row.acceptInvite(token, authUserId)links either shape. - Acceptance via invite code (no email infra). Inviting surfaces a copyable code; the invitee signs in and enters it on the Setup page. A deep-link/email delivery is a forward-compatible follow-up.
- Helper account-switcher is opt-in. The presentation switcher's identity gate is
admin ∪ owner ∪ (helper && canSwitchAccounts). Helpers no longer get automatic switcher access — an admin flips a per-member toggle.Capability.viewHouseholdAllis unchanged (still gates other oversight surfaces); the switcher is presentation-only and never grants a capability.
Kinds vs roles vs accounts (how they stack)
Granting admin/helper requires ALL of: (a) target kind allows it — admin⇒parental,
helper⇒adult (existing kind guards); (b) target is account-linked (this decision);
(c) actor holds manageRoles/admin. Children never get roles regardless of account
(COPPA). Child user accounts are architecturally allowed later but out of MVP-1.
Implementation (shipped)
| Slice | Commit | What |
|---|---|---|
| RG-S1 | 374a250 | setRole linkage guard _requireLinkedAccount (throws DomainRuleException); inviteCoParent defaults {member}. |
| RG-S2 | 19daf02 | canSwitchAccounts member field (Drift v22 + live migration member_can_switch_accounts); switcher gate → admin ∪ owner ∪ flagged-helper. |
| RG-S3 | ef54a32 | inviteMember verb for existing adults; dual-shape invite lifecycle (accept status-agnostic; resend status-preserving; revoke clears-not-deletes for active members). |
| RG-S4 | e0a3faf..f9b3751 | App UI: role-editor linkage gating + hint, helper switcher toggle, member-default invite copy, invite-to-account share-code sheet, SetupPage invite-code accept. |
Dual gate holds at the service layer (_requireLinkedAccount) plus the UI prevention in
the role editor (defense in depth). The Postgres guard-trigger twin is deferred —
see below.
RG-S5 — guard trigger DEFERRED (2026-07-07)
The plan's final hardening was a Postgres trigger rejecting admin/helper role arrays when
auth_user_id is null. A live-row audit before applying it found two rows that would
violate it:
[email protected]— co-parent,invited,{admin}, unlinked → demoted to{member}(2026-07-07). Will be promoted after she accepts.Rebekah— active other-adult,{helper, member}, unlinked (household985566a0). A real, functioning member granted helper before this rule existed.
Applying the trigger would make Rebekah's row uneditable (any update rejected) until
resolved. Owner decision (2026-07-07): invite Rebekah to an account, keep her helper
for now, and DEFER the trigger until she is linked. The service-layer guard
(_requireLinkedAccount) already prevents new violations; the trigger is belt-and-
suspenders on the DB, not the only defense.
Follow-ups to close RG-S5
- After the app redeploys (blocked on the home host being down 2026-07-07), use the "Invite to account" affordance on Rebekah's profile (enter her email) → she signs in → accepts → links → keeps helper (now legitimate).
- Once no
household_membersrow has admin/helper withauth_user_id is null(re-run the audit query), apply the guard trigger:infra/supabase/migrations/<ts>_member_role_requires_account.sql— reject INSERT/UPDATE where roles overlap{admin,helper}andauth_user_id is null. - Promote the spouse placeholder back to admin after she accepts.
Audit query:
select id, display_name, kind, roles, status, auth_user_id
from public.household_members
where auth_user_id is null and roles && array['admin','helper'];
Out of scope (recorded, not built)
- Child/kid user accounts (COPPA HARD gates first) —
inviteMemberchild guard is the seam. - Email/deep-link invite delivery (code entry is the MVP).
- Multi-household account UX (newest-first
memberForAccountstands; schema already supports it).