Add an Adult to the Household — Design Proposal
Date: 2026-07-20 · Branch: feat/mvp1-personas-authz · READ-ONLY design doc — no code, no migrations, no Edge Functions, no Supabase changes.
Method: graphify-first orientation, then targeted reads of the CURRENT tree (not just the 2026-07-11 deep-dive, which predates most of the fixes).
Supersedes/extends: docs/superpowers/specs/2026-07-11-invite-process-completion-design.md (Layer 1 accept RPC + Layer 2 email delivery — now BUILT) and .superpowers/sdd/invite-process-deep-dive.md (the break analysis; most of its CRITICAL/HIGH gaps are since closed — see §1). This proposal adds the admin-direct-create entry path and closes the two remaining app-layer gaps.
0. TL;DR
The owner wants "add an adult to the household" as ONE capability with two entry paths:
- (A) Admin direct-create — an admin makes a co-parent (or other-adult) account directly, sidestepping the email round-trip. This does not exist yet and is the bulk of the new work (it needs an Edge Function because creating an
auth.usersrow requires the service role). - (B) Email invite — reshaped so acceptance actually works. This is essentially already built since the deep-dive: the
accept_inviteSECURITY DEFINER RPC, hash-stored tokens, email delivery, typed error copy, the escalation guard, and the invite-event audit trail all landed in the20260711*migration series + thesend-inviteEdge Function + the SDK cloud routing. What remains for path B is small and app-layer: an "invite sent" feedback polish and an accept surface for accounts that already have a household.
The proven template for path A is the child-auth Edge Function + link_child RPC pair (the child self-signup flow, 20260712*). The adult path mirrors it, minus the COPPA consent gate and minus the child-only synthetic-email machinery.
1. Problem restatement + what the deep-dive flagged vs. current reality
The deep-dive (.superpowers/sdd/invite-process-deep-dive.md, @ 7bcaf2a) found the invite feature was "a well-built inviter-side domain model with a demo-grade acceptance leg," with cloud acceptance architecturally broken: acceptInvite was a caller-household read-modify-write, and under RLS a not-yet-member invitee "can neither see the household, nor read the invited row, nor update it" (deep-dive §0, G1). The root cause is the RLS chicken-and-egg: household_members_select/_update and households_select are members-only (migration 20260612000001), so an outsider cannot insert/activate themselves — the classic "a non-member can't add themselves to the household" problem.
What has since been fixed (verified in the current tree — do NOT re-solve these):
| Deep-dive gap | Status now | Evidence |
|---|---|---|
| G1 — cloud accept path does not exist | FIXED | accept_invite(p_token) SECURITY DEFINER RPC: infra/supabase/migrations/20260711000400_accept_invite_rpc.sql. SDK routes through it in cloud mode: create_client.dart:89 (useRemoteInviteAccept), household_service.dart:600-608 (acceptInvite → acceptInviteRemote). |
| G2 — co-parent invite token never surfaced | FIXED | members_bloc.dart:1340-1357 captures the raw token + bumps coParentInviteAttempt; member_editor_sheet.dart:165-180 listener pops and calls showCoParentCodeSheet; co_parent_code_sheet.dart:56-85 renders copy/regenerate/done. |
| G3 — token not bound / leaked in roster | FIXED | Token now hash-stored (20260711000100_member_invite_token_hash.sql); accept is email-bound (accept_invite step 4, household_service.dart:630-635 local twin). |
| G6/G7 — dup pending invite / already-member | FIXED | Pending-email dedupe household_service.dart:465-473; already-member guard accept_invite step 5 + household_service.dart:636-639. |
| G8/escalation — RLS gated wrong dimension | FIXED | guard_member_privilege_columns BEFORE-UPDATE trigger: 20260711000500_invite_authz_hardening.sql (blocks a linked co_parent self-escalating roles/owner). |
| G9/G10 — legacy admin backfill / server expiry | FIXED | 20260711000300_invite_hygiene_backfill.sql; expiry enforced in accept_invite step 2. |
| G4 — Mom-incident durability | PARTIAL (audit trail) | invite_events append-only table, household_id on delete SET NULL: 20260711000200_invite_events.sql. First-class invites table still deferred (post-MVP). |
| Email delivery | BUILT | infra/supabase/functions/send-invite/index.ts (inviteUserByEmail + redirectTo=https://rewhaven.com/invite?token=…); app calls it fire-and-forget members_bloc.dart:1343. |
| G11 — accept error copy conflation | FIXED | Typed InviteRejectionReason → reason-specific copy: setup/bloc.dart:431-439. |
What is STILL broken / missing (this proposal's actual scope):
- G5 — no accept surface for an account that already has a household. The ONLY accept surface is the Setup page's "Have an invite code?" dialog, shown only on the household-name step of first-run setup (
setup/page.dart:139,232;_InviteCodeDialogL461-551). An onboarded account never sees Setup again (the auth guard routes it intoMainShellRoute), so a separated co-parent who already has their own household — or anyone re-invited — has no way to enter a code. Confirmed still true. - The "invite sent" UI feedback gap the owner reported. On the co-parent invite save, the bloc emits
status: MembersStatus.ready(NOTsaveSuccess) and only later surfaces the code sheet when the token arrives (members_bloc.dart:1351;member_editor_sheet.dart:161-180). There is no "invite sent" snackbar and no inline "Sending…" state — the editor sits unchanged until the code sheet pops, which reads as "nothing happened." This is the exact symptom the owner described. (Note: the email itself IS being sent —sendInviteEmailfires unawaited — so "sends the email but the UI shows nothing" is a pure presentation gap, not a delivery failure.) - Admin direct-create does not exist at all. There is an admin add-member button (
household/page.dart:206-216→MemberEditorSheet, add mode) but its kind picker only offerschildandotherAdultas direct adds;coParentis invite-only, and NO path creates anauth.usersaccount for an adult.addMember(household_service.dart:383-429) creates a member row but never setsauthUserId— it cannot mint an auth account (correctly, since that needs the service role).
The PKCE web code-exchange-on-boot fix (making the email redirectTo actually establish a session) is being built separately; this design assumes it exists and layers on top of a working post-redirect session.
2. The one capability, two entry paths
Both paths converge on the same end state: an adult member row in the household with status='active', roles={member}, linked to an auth.users account, promotable to admin/helper afterward by an admin (the "invites land as member, promote later" control from role-governance decision 2, enforced by guard_member_privilege_columns).
┌─────────────────────────────────────────────┐
PATH A (NEW) │ admin taps "Create co-parent account" │
admin direct-create │ in the admin/members surface (admin-gated) │
└───────────────┬─────────────────────────────┘
│ Edge Function (service role)
│ 1. create auth.users (pre-confirmed or invite email)
│ 2. insert household_members (active, {member}, kind, auth_user_id)
▼
┌──────────────────────┐
│ active linked adult │◄─────────┐
│ member in household │ │
└──────────────────────┘ │ accept_invite RPC
▲ │ (SECURITY DEFINER, email-bound)
PATH B (mostly built) │ inviteCoParent (placeholder row) │
email invite │ + send-invite Edge Fn (email) │
└──── invitee signs in, enters code / taps link ─┘
Path A — Admin direct-create (NEW; mirror child-auth)
Why an Edge Function is mandatory: creating an auth.users row requires auth.admin.createUser, which needs the service-role key. Per the load-bearing rules, the service role is ONLY allowed inside Edge Functions (never in the app; the app holds the anon/publishable key only). This is exactly why child-auth exists. The adult path needs a sibling — call it admin-create-adult (or a new action on a shared members Edge Function).
Template to mirror: infra/supabase/functions/child-auth/index.ts. Reuse its shape:
verify_jwtstays ON; authorize the CALLER server-side: read the caller's JWT, confirm they are an active parent/co_parent AND holdmanageRoles(admin) or owner in the target household — the same dual-bindingchild-auth's parental-delete branch uses (child-auth/index.ts:179-206). This is the service-side half of the DUAL GATE (RLS is the other half — see §3).- Create the auth user with the service role, then insert the
household_membersrow (kind,status='active',roles={member},auth_user_id) via the service client (bypasses RLS cleanly, single owner of the write). - Return typed jsonb
{ok:false, reason:…}(never bareraise) so the SDK MappingPort surfaces reason-specific copy — house style, exactly aslink_child/accept_invite/child-authdo.
COPPA firewall (load-bearing): admin-create-adult MUST reject any non-adult kind server-side (kind.isAdult == false → child). Children have their OWN consent-gated flow (child-auth + link_child + pendingConsent + household approval); the adult creator must not become a child-creation backdoor that skips the VPC gate. The seam already exists in the domain (member_kind.dart:37 isAdult => this != child; the invite path enforces it at household_service.dart:695-699) — the Edge Function enforces the same at the service boundary.
Credential model — this is an OWNER DECISION (see §7 D1). Three viable models, all standard for auth.admin.createUser:
- A1 — temp password shown to the admin once (
createUser({password, email_confirm:true}), return the temp password to surface in an admin code-sheet like the co-parent code-sheet). Admin communicates it out-of-band; adult must change it on first login. - A2 — magic-link / invite email to the adult (
createUsertheninviteUserByEmail, reusing thesend-invitemachinery). This is "direct create" only in that the member row is made immediately; the adult still confirms via email. Closest to path B; weakest "sidestep the email" benefit. - A3 — admin sets the password inline (admin types a password in the create form;
email_confirm:true). Simplest for a trusted single-admin family; adult should be forced to rotate on first login.
Recommendation: A1 (temp password + forced first-login rotation) best matches the owner's stated "sidestep the fragile email round-trip" intent while keeping a credential-rotation control. But this is a genuine choice — see §7.
Path B — Email invite (reshaped; mostly built)
The reshape the deep-dive called for is done. The invitee flow is: admin invites (inviteCoParent creates an invited placeholder row with a hashed token) → send-invite emails an actionable link + code → invitee authenticates (their session established by the PKCE boot fix) → invitee calls accept_invite(rawToken) → the SECURITY DEFINER RPC atomically hashes, validates (found / unexpired / unlinked / email-bound / not-already-member), links auth_user_id, flips status='active', clears the token, and appends an accepted invite-event. This resolves the RLS chicken-and-egg because the definer RPC runs with owner privileges — the not-yet-member invitee never needs SELECT/UPDATE on the household directly.
Remaining path-B work is app-layer only (§4, §5).
3. Dual-gate + RLS story (how a not-yet-member adult attaches exactly once)
The load-bearing DUAL GATE = service authz AND RLS, both must hold.
Path B (email invite):
- Service authz: the accept is email-bound —
accept_inviterequiresauth.email() == invite.email(RPC step 4). Single-use: the RPC clears the token hash on success (RPC step,20260711000400L77-82), so a replay hitsreason:'invalid'. Already-member guard prevents a double-row (RPC step 5). - RLS: the invitee can't read or write the household directly (members-only policies from
20260612000001) — proven by smoke probe 8 in the RPC file ("as B, select … where id = IR.id BEFORE probe 1 → EXPECT 0 rows"). The only attach path is the definer RPC. The escalation guard (guard_member_privilege_columns) then ensures the freshly-linked{member}cannot self-promote to admin/owner via a raw PATCH.
Path A (admin direct-create):
- Service authz: the Edge Function authorizes the CALLER (active parental + admin/owner of the target household) before minting anything, and enforces
isAdulton the requested kind. The service-role client owns the two writes (auth user + member row) atomically-ish (see §6 risk on partial failure). - RLS: the app never writes the member row directly (it can't insert a foreign
auth_user_idunder RLS anyway); the service-role write bypasses RLS by design, so there is no chicken-and-egg. The created member landsactive/{member}— subject to the same escalation guard thereafter.
"Exactly once, scoped to the invite": path B's single-use token hash + already-member guard bound the attach to one invite; path A's already-member check (mirror link_child's 20260712000200 L92-97) prevents a duplicate row for the same (household_id, auth_user_id).
4. Data model — reuse, don't restructure
Reuse the existing household_members table. No new status is needed for adults:
- Path B uses the existing
invitedplaceholder →activetransition (already built). - Path A creates the member directly as
active(adults have no consent gate;addMemberalready mapskind.isAdult ? active : shadowathousehold_service.dart:416). - Do NOT reuse
pendingConsent— that status (20260712000100) exists specifically for the child self-signup VPC gate (a linked child awaiting household consent). An admin-created adult needs no consent gate, so it goes straight toactive. Introducing a "pending adult" status would add ceremony with no COPPA justification and risk conflating the two flows. invite_events(20260711000200) already logsissued/resent/revoked/accepted/expired/deleted. Path A should append anissued-analogue (or a newcreatedkind if we want to distinguish admin-create from email-invite in the audit — a minor, additive choice, not blocking). Path A's created member is not an "invite," so logging is optional-but-nice for the Mom-incident durability posture.
No new columns required for MVP. The token-hash column, invite-event table, and child_join_code_hash already cover everything. (A future first-class invites table remains the deferred structural cleanup from the deep-dive §4 — out of scope here.)
5. The "invite sent" UI feedback fix (path B, small)
Root cause (verified): members_bloc._onSaved invite branch emits status: MembersStatus.ready with a bumped coParentInviteAttempt + coParentInviteToken, but NOT saveSuccess (members_bloc.dart:1349-1357). The editor sheet's listener only (a) closes on saveSuccess — which never fires for invites — or (b) pops + shows the code sheet when the token arrives (member_editor_sheet.dart:161-180). Between tapping "Send invite" and the token arriving there is no visible acknowledgement.
Design (implementation deferred to the plan):
- Add a transient bloc state for the in-flight invite (e.g. an
inviteSending/inviteSentsub-state onMembersState, mirroring howsetup/bloc.dartuses a separateinviteSubmittingsub-state at L397 rather than overloading the wizard status). The editor shows a "Sending invite…" affordance while in-flight and the code sheet + an "Invite sent to" snackbar on success. - Keep the code-sheet reveal (the raw code is the primary artifact the admin must relay), but pair it with explicit sent feedback so the click never reads as a no-op.
- This is presentation-only; the SDK/repo/Edge paths are unchanged.
6. Admin UI surface
Where it lives (verified): members are managed from the Household tab members surface (household/page.dart), and roles/owners from /governance (governance/page.dart). The add-member button (household/page.dart:206-216) opens MemberEditorSheet in add mode.
Gating (verified, load-bearing — preserve):
- Route-level:
AdminGuard(admin_guard.dart:38-41) →member.owner || authorizer.can(member, Capability.manageRoles), fails closed on error (L59). - Capability-level: the invite-to-account verb is
Capability.inviteMember, admin-only (authorizer.dart:77, in_adminCaps).inviteCoParentcurrently takes no acting-member gate (deep-dive G8, noted); the new admin-create path MUST be admin-gated both in the Edge Function (caller authz) AND ideally by movinginviteCoParent/create behind a capability check for symmetry.
Proposed surface: in the members management area, add an admin-only "Add adult" affordance that offers TWO choices — "Create account directly" (path A) and "Invite by email" (path B) — reusing the existing MemberEditorSheet/kind-segment scaffolding and the co_parent_code_sheet pattern for surfacing either the temp password (A1) or the invite code (B). The child add path stays exactly where it is (separate, consent-gated) — the adult affordance must not expose child as a createable kind.
7. Owner decisions (the genuine choices)
D1 — Admin-create credential model. A1 temp-password-shown-once (recommended) vs A2 magic-link/invite-email vs A3 admin-sets-password-inline. Drives the Edge Function's createUser call and the admin UI (code-sheet vs email vs password field). This is the most consequential and security-sensitive decision.
D2 — Force first-login password rotation for admin-created adults? If A1 or A3, do we require the adult to set their own password on first login (recommended: yes, so the admin-known temp credential can't persist)? Requires a first-login "set your password" gate.
D3 — Keep the email-invite path at all, or admin-create-only? Path B is already built and working (post-PKCE-fix); path A is new. Do we ship BOTH entry paths (recommended — email invite serves remote/separated co-parents who each have their own household), or does admin-direct-create fully replace it for MVP? If we keep B, we also owe the G5 accept surface for existing-household accounts (a "Join a household with a code" entry in Settings/Household) — currently missing.
D4 — Audit path A in invite_events? Log admin-created adults as a distinct created kind (additive) for Mom-incident durability parity, or leave direct-create unlogged for MVP? Low-stakes; flag only.
8. Scope: MVP vs deferred; security-sensitive flags
MVP:
- Path A
admin-create-adultEdge Function (service-role, caller-authz,isAdultfirewall, typed reasons) + SDK facade→service→adapter route + admin UI. [SECURITY-SENSITIVE: service-role auth-user creation + caller authorization — this is the highest-risk new surface. Mirrorchild-auth's dual-binding authz exactly and route throughsecurity-reviewer.] - Path B "invite sent" feedback polish (§5).
- Path B G5 accept surface for existing-household accounts (only if D3 keeps email-invite).
- First-login password rotation if D1/D2 select a temp-password model. [SECURITY-SENSITIVE.]
Deferred (already tracked or non-blocking):
- First-class
invitestable restructure (deep-dive §4 step 3) — post-MVP structural. - Branded mail provider (Resend/SendGrid) — the
send-inviteEdge Function is the seam. - Native Android App-Link verification (assetlinks + prod cert) — sequenced with the Android release.
- Child/kid adult-path invites — permanently out of scope; the
isAdultguard is the firewall. - Multi-household switcher UX (deep-dive G12) — newest-first stands.
Security-sensitive parts to route through security-reviewer before merge:
- The
admin-create-adultEdge Function (service-role key handling, caller authorization,isAdultenforcement, partial-failure cleanup if auth-user creation succeeds but member insert fails — mirrorchild-auth's delete/rollback branch). - Any temp-password generation/transport (entropy, one-time display, forced rotation).
- Confirming the escalation guard (
guard_member_privilege_columns) also covers path-A-created members (it does — it gates the column, not the creation path).
Appendix — key file:line index (current tree)
- Accept RPC:
infra/supabase/migrations/20260711000400_accept_invite_rpc.sql - Child-link template:
infra/supabase/migrations/20260712000200_link_child_rpc.sql;infra/supabase/functions/child-auth/index.ts - Email delivery:
infra/supabase/functions/send-invite/index.ts; adaptersupabase_households.dart:217-221 - Escalation guard / hardening:
20260711000500_invite_authz_hardening.sql; audit trail20260711000200_invite_events.sql; token hash20260711000100; hygiene backfill20260711000300; pendingConsent status20260712000100 - SDK service:
packages/client_sdk/lib/src/services/household_service.dart—inviteCoParentL446,resendInviteL524,revokeInviteL560,acceptInviteL591 (remote route L600),inviteMemberL684 (isAdult guard L695),addMemberL383 (status-by-kind L416),sendInviteEmailL510 - SDK routing:
packages/client_sdk/lib/src/client/create_client.dart:89-91(useRemoteInviteAccept/useRemoteChildLink) - Kind model:
packages/client_sdk/lib/src/models/member_kind.dart:37(isAdult), L28 (isParental) - Authorizer:
packages/client_sdk/lib/src/services/authorizer.dart—inviteMemberin_adminCapsL77, status gate L159 - Repository delegates:
app/lib/outside/repositories/household/household_repository.dart:230-280 - Members bloc:
app/lib/inside/blocs/household/members_bloc.dart— invite save L1314-1366 (feedback gap L1351), resend L1447, account-invite L1507, child-attach L1600-1645 - Invite UI:
member_editor_sheet.dart:156-180(listener),co_parent_code_sheet.dart:56-85,account_invite_sheet.dart,invite_actions_sheet.dart - Accept surface (setup-only, G5):
app/lib/inside/blocs/setup/bloc.dart:389-450;app/lib/inside/routes/authenticated/setup/page.dart:139,232,461 - Admin surface + gating:
app/lib/inside/routes/authenticated/household/page.dart:206-216(add-member button);app/lib/inside/routes/authenticated/governance/page.dart;app/lib/inside/routes/guards/admin_guard.dart:38-41 - Child approval surface (pattern reference):
child_approvals_section.dart,child_approval_sheet.dart - Prior specs:
docs/superpowers/specs/2026-07-11-invite-process-completion-design.md,2026-07-12-child-self-signup-consent-gate-design.md; deep-dive.superpowers/sdd/invite-process-deep-dive.md
OWNER DECISIONS — LOCKED 2026-07-20
- D1 credential model = TEMP PASSWORD, shown once. The
admin-create-adultEdge Function generates a one-time temp password, returned to the admin UI to display/hand off. No email dependency. - D2 force first-login rotation = YES. The admin-created adult MUST set a new password on first sign-in (temp password is single-use / flagged must-change). Design the must-change signal (e.g. a member/profile flag or Supabase user metadata) + the first-login gate.
- D3 scope = BOTH paths. (A) admin direct-create (the new Edge Function) AND (B) finish the ~90%-built email-invite: fix the "invite sent" feedback (bloc emits ready/no snackbar) + add the missing "join a household with a code" ACCEPT surface for an authenticated adult who has no household yet (deep-dive G5), calling the existing accept_invite RPC.
- D4 audit = YES, additive. Log admin-created adults in invite_events as a distinct
createdkind. - Adults go straight to
active/{member}role (NO pendingConsent reuse — that is child-COPPA only). The isAdult firewall MUST hold: neither path may create/onboard a child (children keep their own COPPA-gated flow). - SECURITY-SENSITIVE (route through security-reviewer): the service-role Edge Function (caller authz + isAdult enforcement + partial-failure rollback), temp-password generation/transport, and the first-login must-change gate.
- Assume the web PKCE code-exchange-on-boot fix (built separately, same day) exists — the email-invite accept path relies on the redirect establishing a session.