Child Self-Signup with Household Consent Gate — Design Spec
Date: 2026-07-12
Status: Approved (brainstorming) — pending spec review → writing-plans
Extends: the persona/authz + consent model in
docs/superpowers/specs/2026-06-25-mvp1-personas-authz-foundation-design.md,
docs/decisions/2026-07-04-role-governance.md, and the privacy/compliance model.
Reuses the invite machinery from
docs/superpowers/specs/2026-07-11-invite-process-completion-design.md
(join-with-code, SECURITY DEFINER accept RPC, capability guard).
Goal
Let a child (under-13) self-sign-up into rewhaven and use the app in the
existing supervised child mode — but hold their account in a frozen pending
state until the household completes a verifiable parental-consent (VPC)
agreement, at which point the child is activated. Today children are
loginless, parent-created profiles (the COPPA isAdult seam blocks inviting
them). This adds child-initiated accounts anchored to a household, with async
household consent as the activation gate.
Why (the change)
The VPC gate already exists: ConsentService.captureConsent (parental-only)
flips a child member's consentState to granted, and assertChildDataAllowed
throws ConsentRequired until it does. What is missing is (1) a child obtaining
their own auth login, (2) a pending-activation state for a child who has
linked a login but is not yet consented, (3) surfacing that pending child as a
household approval item, and (4) an RLS freeze so an unconsented child
can touch nothing. This spec adds exactly those four pieces on top of the
existing member + consent + join-with-code model (chosen approach A over a
standalone onboarding subsystem or a parent-only flow).
Decisions (locked in brainstorming)
- Entry is PARENT-ANCHORED via a code. A child starts by entering a code a parent controls. Consequence: the parent already designated this person as a child (and their age/DOB) when creating the profile or the household — so the child never self-declares age, sidestepping the neutral-age-gate problem.
- BOTH profile-link paths are supported, converging on one end state:
- Attach (per-child code): parent pre-creates the child (
shadow,kind=child, age set), shares a per-child code; the child's login attachesauthUserIdto that row (shadow → pendingConsent). - New (household code): a general household join code creates a fresh
kind=childmember directly inpendingConsent; age is set by the parent at the approval step.
- Attach (per-child code): parent pre-creates the child (
- Child login = USERNAME + PASSWORD (reuse the app's existing username-based auth, #155). No email required; the household anchors identity. This deliberately does NOT use the email-bound adult accept path.
- New status
MemberStatus.pendingConsent— a child with a linked login but no household VPC yet. Distinct fromshadow(loginless placeholder),active(live),invited(adult invite). - The freeze is a TRIPLE gate (UI + SDK service + RLS). No single miss opens a hole.
- Consent = existing
captureConsent(parental-only). Any parental/admin member can approve (not only the owner). On grant:consentState → granted,status → active, child enters normal supervised child mode ("TV stance"). - Decline / expiry deletes the pending account (member row + child
auth.users). Expiry window = 7 days. This is also the COPPA delete-if-no-consent requirement. - 13+ at approval (new-child path) converts to the ADULT member path, not a child.
- COPPA data-minimization: before consent, store only username + password hash + display name (+ age on the attach path). No activity, wallet, traits, or other PII until consent.
Architecture — approach A (extend existing machinery)
Two axes already in the model drive one state machine: status (linked
login / live) and consentState (household VPC).
attach path: parent pre-creates child (shadow, kind=child, age) -> per-child code ┐
new path: parent shares general household code ┘
│ child enters code + username/password
v
status = pendingConsent · authUserId linked · consentState = notGranted
│ FROZEN -> sees only "waiting for a grown-up"
parent captureConsent (VPC) │ parent declines / 7-day expiry
v v
status = active · consentState = granted member row + child auth.users deleted
(supervised child mode)
Schema / Postgres (infra/supabase/migrations/)
MemberStatus.pendingConsentadded to the status domain (CHECK constraint).- Child-link RPC (SECURITY DEFINER, house pattern — sibling to
accept_invite): pinnedsearch_path='public',revoke execute from public, anon,grant to authenticated. Validates the code and:- attach path — confirms the target row is
kind=child+ unlinked, linksauthUserId, setsstatus='pendingConsent'; - new path — creates the
kind=childrow inpendingConsent. - Binding = the code itself (per-child or household join code the parent controls) — there is no email to bind to; the code is the capability.
- Domain failures RETURNED as jsonb
{ok:false, reason:...}(not raised) —invalid_code/expired/already_linked/not_a_child— mapped by the SDK to typed exceptions -> UI copy (same pattern asaccept_invite).
- attach path — confirms the target row is
- RLS freeze (the load-bearing security change): a
pendingConsentchild is a member (household_idpresent), so the membership helpers (member_household_ids()and friends) must be tightened to exclude a child whoseconsentState != granted. Gate on the consent dimension, not just membership — the same lesson as the invite capability guard. Net effect: an unconsented child canSELECT/INSERT/UPDATEnothing household-scoped. The one allowed read is their own member row'sstatus(poll "am I approved yet?"), via a narrow self-scoped policy. captureConsentstays parental-only at RLS + service (unchanged).- Cleanup: decline/expiry deletes the member row and the child
auth.usersaccount (lazy on next parental view + a scheduled sweep).
SDK (packages/client_sdk)
- Child signup service — routes through the child-link RPC in cloud; local/in-memory keeps a direct path with the same validations for parity. Typed reasons -> typed exceptions.
pendingConsentin the member model + codec (Drift + cloud).assertChildDataAllowedalready gates unconsented children — extend its coverage to the self-signup entry (same gate, new origin).- Pending-child read for the household approval surface (list children in
pendingConsent). - Activation = existing
captureConsent(no new verb) -> projectsgranted+active. Decline = a new verb that deletes the pending member- account. Revoke = existing
revokeConsentre-freezes an active child.
- account. Revoke = existing
App UI (app/lib/inside + auth)
- Child signup screen: enter code -> username + password + display name (new-child path) or username + password (attach path, name pre-filled).
- Waiting screen: a
pendingConsentchild is routed here and ONLY here — no tabs, no household data, no editable profile; polls status. - Household approval surface: any parental/admin member sees "‹name› is
waiting to join" (reuse the approvals/members surface) -> opens the VPC
agreement ->
captureConsent; also Decline. New-child path collects/confirms age here (13+ -> adult conversion).
Data flow (happy path)
Parent shares a code -> child enters it + picks username/password (+ name) ->
child-link RPC links/creates a kind=child member in pendingConsent -> child
lands on the waiting screen (frozen at UI + SDK + RLS) -> any parental member sees
the pending-approval item -> completes the VPC agreement -> captureConsent
flips granted + active -> child drops into supervised child mode.
Error handling
- Typed reasons end-to-end (RPC reason -> SDK typed exception -> UI copy):
invalid_code/expired/already_linked/not_a_child. - Wrong/expired/reused code -> no account created.
- Decline or 7-day expiry -> member + child
auth.usersdeleted. - Household deleted while a child is pending -> orphaned child account cleaned up.
- Parent revokes consent on an active child -> re-frozen (waiting screen).
- Offline/local single-user mode -> child self-signup is a cloud concern (N/A).
Security
- Triple freeze: UI route lock +
assertChildDataAllowed+ RLS exclusion of unconsented children from the membership helpers. Dual gate (service AND RLS) for every privileged step, per the load-bearing rules. - Child-link RPC follows the SECURITY DEFINER house pattern (pinned search_path, revoked from anon, granted to authenticated, typed jsonb reasons).
- VPC audit trail:
captureConsentwrites aConsentRecord(method + timestamp + granting adult) to theconsentstable. - COPPA data-minimization: only credentials + display name (+ attach-path age) before consent; deletion on decline/expiry.
- Children remain non-invitable through the adult invite path (the
isAdultguard oninviteMember/inviteCoParentis unchanged); this is a separate, consent-gated entry.
Testing
- Load-bearing (mirrors the invite two-identity test): a two-auth-identity
cloud test — a child identity in
pendingConsentproves it is frozen at RLS (reads/writes nothing household-scoped; can read only its ownstatus), then active after a parent'scaptureConsent. Plus decline->deletion, wrong-code, parental-only consent, and the 13+ conversion. - SDK unit: child-link RPC routing, pending state, gate, activation, decline cleanup, typed reasons.
- App flow: child signup + waiting screen; parent approval surface; typed code errors.
- Live smoke: child-link RPC + freeze + activation on the deployed project (house style, simulated JWTs where a headless client can't attach the token).
Phasing (ship in two layers, like invite)
- Layer 1 (core, independently shippable via code entry):
pendingConsentstatus + child-link RPC + RLS freeze + consent-activation + decline + child signup screen + waiting screen + parent approval surface. - Layer 2 (delivery/polish): household notification ("a child is waiting"), auto-expiry/cleanup job, tier-varying VPC method strength, decline-UX niceties.
Out of scope / deferred
- The sufficiency of any specific VPC method (see the launch gate below) — engineering supports varying methods; which clears the bar is a legal call.
- Household notification transport (push/email) — Layer 2.
- Self-governed / peer modes (already deferred) — unaffected; a self-signed-up
child still gets a member identity that can later carry
kids.identity_idcontinuity (divorce-clone) without conflict. - 13+ full adult-conversion UX beyond routing to the adult path.
Dependencies / coordination
- Supabase project
bgedvvmihygwxhjxlvfu: the child-link RPC +pendingConsentstatus + tightened membership-helper RLS + cleanup, applied live after review (house guard-RPC pattern). - Existing username-based auth (#155) — the child credential mechanism.
- Existing consent machinery —
captureConsent/revokeConsent/assertChildDataAllowed/consentstable /ConsentRecord/ConsentMethod.
Decision 3 (username + password, no real email) is realized via a service-role
child-auth Edge Function (infra/supabase/functions/child-auth/index.ts).
Because a child has no real email and the project's email-confirm is ON, the
client-side AuthRepository.signUp+confirm path cannot provision them. Instead
child-auth (service-role key, injected into the Edge runtime only) creates
the child's Supabase auth user under a synthetic, non-deliverable email
(child.<username>@child.rewhaven.invalid, reserved .invalid TLD),
pre-confirmed (email_confirm: true) so it bypasses the email-confirm wall,
and deletes it on decline / rollback / 7-day expiry. The child never sees the
synthetic email — it is an internal identity, not real PII; the child UX stays
username + password. The app invokes it through the SDK (anon key for the
pre-auth create; the caller's JWT for delete), then signs in with the
returned synthetic email and calls the child-link RPC as the authenticated
child, so the household binding still runs inside the definer RPC.
Launch gate (HARD — non-engineering)
Before production: a COPPA legal review of the VPC method — whether free-tier
"email-plus" and paid-tier "card-on-file" each clear §312.5's "reasonably
calculated to ensure the person is the parent" bar, and that the
delete-if-no-consent window + data-minimization satisfy §312.5/§312.10. The
mechanism (ConsentMethod) supports whatever is chosen; sufficiency is a legal
determination, not an engineering one.
Success criteria
A child can enter a parent's code, sign up with a username + password, and land in a frozen pending state where — verified by a two-identity cloud test — they can read nothing but their own status; a parental member then completes the VPC agreement and the child becomes an active supervised member; declining or letting it expire deletes the pending account and its minimal data.