Skip to main content

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):

ConceptWhatWhereScope
Userauth account, one per humanSupabase auth.usersglobal
Memberhousehold profile (name, kind, roles, wallet…)household_members rowone household
Linkmember → userhousehold_members.auth_user_id — nullable, NON-uniqueone 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

  1. Roles beyond member require a linked account. Admin/helper can only be held by a member with auth_user_id != null. Unlinked profiles (kids without accounts, shadow adults) are member-only until invited + linked.
  2. 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.
  3. 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.
  4. Two invite shapes, one acceptance. New co-parent → invited placeholder member (reserved seat, deleted on revoke). Existing adult member → inviteMember stamps a token on the live row, status stays active (member keeps functioning), and revoke clears the invite fields, never deletes the row. acceptInvite(token, authUserId) links either shape.
  5. 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.
  6. 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.viewHouseholdAll is 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)

SliceCommitWhat
RG-S1374a250setRole linkage guard _requireLinkedAccount (throws DomainRuleException); inviteCoParent defaults {member}.
RG-S219daf02canSwitchAccounts member field (Drift v22 + live migration member_can_switch_accounts); switcher gate → admin ∪ owner ∪ flagged-helper.
RG-S3ef54a32inviteMember verb for existing adults; dual-shape invite lifecycle (accept status-agnostic; resend status-preserving; revoke clears-not-deletes for active members).
RG-S4e0a3faf..f9b3751App 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.
  • Rebekahactive other-adult, {helper, member}, unlinked (household 985566a0). 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

  1. 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).
  2. Once no household_members row has admin/helper with auth_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} and auth_user_id is null.
  3. 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) — inviteMember child guard is the seam.
  • Email/deep-link invite delivery (code entry is the MVP).
  • Multi-household account UX (newest-first memberForAccount stands; schema already supports it).