Skip to main content

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)

  1. 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.
  2. 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 attaches authUserId to that row (shadow → pendingConsent).
    • New (household code): a general household join code creates a fresh kind=child member directly in pendingConsent; age is set by the parent at the approval step.
  3. 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.
  4. New status MemberStatus.pendingConsent — a child with a linked login but no household VPC yet. Distinct from shadow (loginless placeholder), active (live), invited (adult invite).
  5. The freeze is a TRIPLE gate (UI + SDK service + RLS). No single miss opens a hole.
  6. 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").
  7. 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.
  8. 13+ at approval (new-child path) converts to the ADULT member path, not a child.
  9. 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.pendingConsent added to the status domain (CHECK constraint).
  • Child-link RPC (SECURITY DEFINER, house pattern — sibling to accept_invite): pinned search_path='public', revoke execute from public, anon, grant to authenticated. Validates the code and:
    • attach path — confirms the target row is kind=child + unlinked, links authUserId, sets status='pendingConsent';
    • new path — creates the kind=child row in pendingConsent.
    • 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 as accept_invite).
  • RLS freeze (the load-bearing security change): a pendingConsent child is a member (household_id present), so the membership helpers (member_household_ids() and friends) must be tightened to exclude a child whose consentState != granted. Gate on the consent dimension, not just membership — the same lesson as the invite capability guard. Net effect: an unconsented child can SELECT/INSERT/UPDATE nothing household-scoped. The one allowed read is their own member row's status (poll "am I approved yet?"), via a narrow self-scoped policy.
  • captureConsent stays parental-only at RLS + service (unchanged).
  • Cleanup: decline/expiry deletes the member row and the child auth.users account (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.
  • pendingConsent in the member model + codec (Drift + cloud).
  • assertChildDataAllowed already 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) -> projects granted + active. Decline = a new verb that deletes the pending member
    • account. Revoke = existing revokeConsent re-freezes an active child.

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 pendingConsent child 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.users deleted.
  • 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: captureConsent writes a ConsentRecord (method + timestamp + granting adult) to the consents table.
  • 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 isAdult guard on inviteMember / inviteCoParent is 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 pendingConsent proves it is frozen at RLS (reads/writes nothing household-scoped; can read only its own status), then active after a parent's captureConsent. 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): pendingConsent status + 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_id continuity (divorce-clone) without conflict.
  • 13+ full adult-conversion UX beyond routing to the adult path.

Dependencies / coordination

  • Supabase project bgedvvmihygwxhjxlvfu: the child-link RPC + pendingConsent status + tightened membership-helper RLS + cleanup, applied live after review (house guard-RPC pattern).
  • Existing username-based auth (#155) — the child credential mechanism.
  • Existing consent machinerycaptureConsent / revokeConsent / assertChildDataAllowed / consents table / 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.