Skip to main content

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.users row requires the service role).
  • (B) Email invite — reshaped so acceptance actually works. This is essentially already built since the deep-dive: the accept_invite SECURITY DEFINER RPC, hash-stored tokens, email delivery, typed error copy, the escalation guard, and the invite-event audit trail all landed in the 20260711* migration series + the send-invite Edge 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 gapStatus nowEvidence
G1 — cloud accept path does not existFIXEDaccept_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 (acceptInviteacceptInviteRemote).
G2 — co-parent invite token never surfacedFIXEDmembers_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 rosterFIXEDToken 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-memberFIXEDPending-email dedupe household_service.dart:465-473; already-member guard accept_invite step 5 + household_service.dart:636-639.
G8/escalation — RLS gated wrong dimensionFIXEDguard_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 expiryFIXED20260711000300_invite_hygiene_backfill.sql; expiry enforced in accept_invite step 2.
G4 — Mom-incident durabilityPARTIAL (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 deliveryBUILTinfra/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 conflationFIXEDTyped InviteRejectionReason → reason-specific copy: setup/bloc.dart:431-439.

What is STILL broken / missing (this proposal's actual scope):

  1. 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; _InviteCodeDialog L461-551). An onboarded account never sees Setup again (the auth guard routes it into MainShellRoute), 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.
  2. The "invite sent" UI feedback gap the owner reported. On the co-parent invite save, the bloc emits status: MembersStatus.ready (NOT saveSuccess) 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 — sendInviteEmail fires unawaited — so "sends the email but the UI shows nothing" is a pure presentation gap, not a delivery failure.)
  3. Admin direct-create does not exist at all. There is an admin add-member button (household/page.dart:206-216MemberEditorSheet, add mode) but its kind picker only offers child and otherAdult as direct adds; coParent is invite-only, and NO path creates an auth.users account for an adult. addMember (household_service.dart:383-429) creates a member row but never sets authUserId — 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_jwt stays ON; authorize the CALLER server-side: read the caller's JWT, confirm they are an active parent/co_parent AND hold manageRoles (admin) or owner in the target household — the same dual-binding child-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_members row (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 bare raise) so the SDK MappingPort surfaces reason-specific copy — house style, exactly as link_child/accept_invite/child-auth do.

COPPA firewall (load-bearing): admin-create-adult MUST reject any non-adult kind server-side (kind.isAdult == falsechild). 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 (createUser then inviteUserByEmail, reusing the send-invite machinery). 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_invite requires auth.email() == invite.email (RPC step 4). Single-use: the RPC clears the token hash on success (RPC step, 20260711000400 L77-82), so a replay hits reason:'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 isAdult on 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_id under RLS anyway); the service-role write bypasses RLS by design, so there is no chicken-and-egg. The created member lands active/{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 invited placeholder → active transition (already built).
  • Path A creates the member directly as active (adults have no consent gate; addMember already maps kind.isAdult ? active : shadow at household_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 to active. Introducing a "pending adult" status would add ceremony with no COPPA justification and risk conflating the two flows.
  • invite_events (20260711000200) already logs issued/resent/revoked/accepted/expired/deleted. Path A should append an issued-analogue (or a new created kind 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/inviteSent sub-state on MembersState, mirroring how setup/bloc.dart uses a separate inviteSubmitting sub-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). inviteCoParent currently 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 moving inviteCoParent/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-adult Edge Function (service-role, caller-authz, isAdult firewall, 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. Mirror child-auth's dual-binding authz exactly and route through security-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 invites table restructure (deep-dive §4 step 3) — post-MVP structural.
  • Branded mail provider (Resend/SendGrid) — the send-invite Edge 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 isAdult guard is the firewall.
  • Multi-household switcher UX (deep-dive G12) — newest-first stands.

Security-sensitive parts to route through security-reviewer before merge:

  1. The admin-create-adult Edge Function (service-role key handling, caller authorization, isAdult enforcement, partial-failure cleanup if auth-user creation succeeds but member insert fails — mirror child-auth's delete/rollback branch).
  2. Any temp-password generation/transport (entropy, one-time display, forced rotation).
  3. 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; adapter supabase_households.dart:217-221
  • Escalation guard / hardening: 20260711000500_invite_authz_hardening.sql; audit trail 20260711000200_invite_events.sql; token hash 20260711000100; hygiene backfill 20260711000300; pendingConsent status 20260712000100
  • SDK service: packages/client_sdk/lib/src/services/household_service.dartinviteCoParent L446, resendInvite L524, revokeInvite L560, acceptInvite L591 (remote route L600), inviteMember L684 (isAdult guard L695), addMember L383 (status-by-kind L416), sendInviteEmail L510
  • 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.dartinviteMember in _adminCaps L77, 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-adult Edge 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 created kind.
  • 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.