Skip to main content

Child Self-Signup with Household Consent Gate — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a child (under-13) self-sign-up with a username + password against a code a parent controls, land in a frozen pendingConsent state where — enforced at UI, SDK service, AND RLS — they can read nothing but their own member-row status, and be activated into normal supervised child mode only when any parental/admin member completes the verifiable-parental-consent (VPC) agreement (captureConsent). Declining or a 7-day expiry deletes the pending member row and the child auth.users account (COPPA delete-if-no-consent).

Architecture: Two layers on top of the existing member + consent + join-with-code machinery. Layer 1 (core, independently shippable via code entry): a new MemberStatus.pendingConsent; a link_child SECURITY DEFINER RPC (attach path links a login to a pre-created shadow kind=child row, new path mints a fresh kind=child in pendingConsent from a household join code); the load-bearing RLS freeze that tightens member_household_ids() so a kind=child row counts only when consent_state='granted', plus a narrow self-row read; an SDK child-signup service (cloud→RPC, in-memory parity, typed reasons→typed exceptions) on username+password auth; activation via the existing captureConsent; a decline verb; the child signup + waiting screens; the household approval surface; and the load-bearing SQL-smoke freeze/activation live proof. Layer 2 (delivery/polish): household "a child is waiting" notification, the 7-day auto-expiry sweep, tier-varying VPC method strength, decline-UX niceties.

Tech Stack: Dart 3.9 / Flutter 3.44 (FVM-pinned), flutter_bloc, auto_route ^10, equatable, json_annotation + json_serializable, Drift (local), Supabase Postgres + PostgREST + Edge Functions (Deno), pgcrypto. Single Dart pub workspace (one lockfile). Reuses crypto (hashInviteToken) and the PostgrestPort.rpc seam landed by the invite feature.

Global Constraints

Every task's requirements implicitly include this section. These are binding rules copied from the spec:

  • One data path: Bloc → Repository → Client (facade) → Service → Adapter. Domain rules live in the SDK Service (or its SECURITY DEFINER cloud twin); repositories are thin presentation delegates.
  • DUAL GATE: every privileged invariant enforced in the RPC/service AND in RLS/schema. Never one alone.
  • TRIPLE FREEZE of an unconsented child: UI route lock (routed to the waiting screen and ONLY there) + ConsentService.assertChildDataAllowed (SDK) + RLS exclusion from member_household_ids(). No single miss opens a hole.
  • Actor = the authenticated member, resolved from auth.uid() / the acting member id — never a viewingAs lens or heuristic.
  • Children stay non-invitable through the adult invite path — the COPPA isAdult guard on inviteMember/inviteCoParent is unchanged; child self-signup is a separate, consent-gated entry via link_child.
  • COPPA data-minimization: before consent store ONLY username + password-hash (in auth.users) + display name (+ age on the attach path, pre-set by the parent). No activity, wallet, traits, or other child PII until consent. Decline / 7-day expiry deletes the member row AND the child auth.users account (member row via the decline_child RPC / expiry sweep; the auth.users row via the service-role child-auth Edge Function).
  • Child Supabase-auth lifecycle is owned by the service-role child-auth Edge Function (spec decision 3). A child has NO real email and the project's email-confirm is ON, so the client-side AuthRepository.signUp+confirm path cannot be used. Instead: child-auth create mints a PRE-CONFIRMED (email_confirm: true) auth user under a SYNTHETIC, non-deliverable email (child.<username>@child.rewhaven.invalid, reserved .invalid TLD, never shown to anyone, not real PII); child-auth delete removes it on decline/rollback/expiry. The child UX stays username + password — the synthetic email is an internal identity the child never sees. The function uses the injected SUPABASE_SERVICE_ROLE_KEY only inside the Edge runtime; the app invokes it through the SDK's db.invokeFunctionResult('child-auth', ...) seam (anon key for the pre-auth create; the caller's JWT for delete).
  • SECURITY DEFINER house pattern: pinned search_path = 'public'; revoke execute ... from public, anon; grant execute ... to authenticated for client-called RPCs (link_child, decline_child); domain-expected failures are RETURNED as jsonb {ok:false, reason:...}, never raised (the MappingPort collapses every raise/P0001 to a generic StorageFailure).
  • Anon/publishable key ONLY. Supabase project bgedvvmihygwxhjxlvfu. The service-role key never ships in the app.
  • Migrations are file-only — the controller applies them live after review (never mcp apply_migration from a task). Each migration carries LIVE SMOKE PROBES in a trailing comment block (house style). Migration tasks say "controller applies + smokes after review".
  • The LOAD-BEARING test is the SQL-smoke child freeze/activation proof: a Postgres DO-block with simulated JWTs (set_config('request.jwt.claims', json, true)auth.uid()/auth.email()), NOT a headless flutter HTTP test. Rationale: the project's sb_publishable_ key does not attach a JWT in a headless flutter test client (requests run anon), exactly as documented for the invite two-identity live test. The flutter live test is authored self-skipping; the executed go-live proof is the SQL smoke.
  • FVM: run fvm flutter ... / fvm dart ... (never bare flutter/dart/node).
  • graphify-first: for any codebase question run graphify query "<q>" before grepping/reading raw source. After modifying code run graphify update . (do NOT stage graphify-out/).
  • Explicit git add <paths> — never git add -A/.; never stage graphify-out/, .superpowers/, or .claude/.
  • Suite baselines must not drop: app 553 tests, SDK 1049 tests (narrative counts read from the All tests passed / N passed summary, per .superpowers/sdd/progress.md; not asserted by a script). New tasks only ADD tests.
  • Layer 1 is independently shippable before Layer 2.
  • HARD legal gate before production (non-engineering): a COPPA VPC-method sufficiency review — 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. Engineering supports a varying ConsentMethod; sufficiency is a legal determination.

File Structure

Schema (migration files — controller applies live in filename order):

  • infra/supabase/migrations/20260712000100_member_status_pending_consent.sql — add 'pendingConsent' to the household_members_status_check CHECK.
  • infra/supabase/migrations/20260712000200_link_child_rpc.sqlhouseholds.child_join_code_hash column + link_child(p_code, p_display_name) SECURITY DEFINER RPC (attach + new paths, typed jsonb reasons).
  • infra/supabase/migrations/20260712000300_child_consent_rls_freeze.sql — tighten member_household_ids() to exclude non-granted children + household_members_self_read narrow self-row SELECT policy (the load-bearing freeze).
  • infra/supabase/migrations/20260712000400_decline_child_rpc.sqldecline_child(p_member_id) SECURITY DEFINER RPC (parental-gated; deletes the member row and RETURNS the child's auth_user_id + household_id so the caller can delete the auth user via the child-auth Edge Function).

Edge Functions (infra/supabase/functions/ — service-role, IaC, deployed by the controller):

  • infra/supabase/functions/child-auth/index.ts — NEW service-role function that owns the child Supabase-auth lifecycle (spec decision 3): create mints a PRE-CONFIRMED auth user under a SYNTHETIC, non-deliverable email (child.<username>@child.rewhaven.invalid) so an email-less child bypasses the email-confirm wall; delete removes a child auth user on decline / rollback. Uses the injected SUPABASE_SERVICE_ROLE_KEY (never shipped in the app); mirrors the send-invite house style.

SDK (packages/client_sdk):

  • lib/src/models/member_status.dart — add pendingConsent enum value + wireName/fromWireName.
  • lib/src/models/household_member.dart — extend the exhaustive hasAccess switch for pendingConsent.
  • lib/src/models/exceptions.dartChildLinkRejectionReason + ChildLinkException.
  • lib/src/services/child_signup_service.dart — NEW service: provisionChildAuth (child auth mint via child-auth), signUpChild (child redeem), deleteChildAuth (rollback), issueChildAttachCode / issueHouseholdChildJoinCode (parent issue), pendingConsentChildren (parent read), declineChild (parent decline).
  • lib/src/services/consent_service.dartcaptureConsent also flips a pendingConsent child to active (activation); a gate-coverage test for the self-signup origin.
  • lib/src/adapters/cloud/cloud_rows.dartPostgrestPort: add invokeFunctionResult(fn, body) → Future<Map<String,dynamic>> (a result-returning Edge-Function invoke sibling to the fire-and-forget invokeFunction, needed for the child-auth {ok,...} response). Threaded through mapping_port.dart + supabase_storage_adapter.dart + test/cloud/fake_postgrest.dart.
  • lib/src/adapters/adapter.dartStoragePort: add linkChildRemote, declineChildRemote, provisionChildAuthRemote, deleteChildAuthRemote.
  • lib/src/adapters/cloud/supabase_households.dart — implement linkChildRemote (via db.rpc), provisionChildAuthRemote/deleteChildAuthRemote (via db.invokeFunctionResult('child-auth', ...)), and declineChildRemote (db.rpc('decline_child') then child-auth delete); reason→exception mapper.
  • lib/src/adapters/memory/in_memory_storage_adapter.dart + cached/cached_storage_adapter.dart + local/local_storage_adapter.dart — parity / cloud-only stubs.
  • lib/src/client/client.dart + client_impl.dart + create_client.dart — facade wiring for the child-signup service (useRemoteChildLink = dataMode == cloud).
  • lib/client_sdk.dart — barrel exports for the new service surface + exceptions.
  • test/support/fake_port.dart — stubs for the two new StoragePort members.
  • test/services/child_signup_service_test.dart (NEW), test/services/consent_service_test.dart, test/cloud/link_child_routing_test.dart (NEW), test/cloud/child_freeze_live_test.dart (NEW, self-skipping), test/models_round_trip_test.dart.

App (app/lib):

  • lib/inside/routes/unauthenticated/child_signup/child_signup_page.dart + bloc.dart + state.dart (NEW) — enter code + username/password + display name.
  • lib/inside/routes/authenticated/waiting/waiting_page.dart (NEW) — the frozen child's only screen; polls status.
  • lib/inside/routes/authenticated/members/child_approvals_*.dart (NEW) — the "‹name› is waiting" approval item → VPC agreement → captureConsent/decline; new-child age set + 13+ conversion.
  • lib/inside/routes/router.dart (+ router.gr.dart) — ChildSignupRoute (unauthenticated), WaitingRoute; guard routes a frozen child to WaitingRoute only.
  • lib/outside/repositories/household/household_repository.dartprovisionChildAuth / signUpChild / deleteChildAuth / pendingConsentChildren / declineChild / issueChildAttachCode / issueHouseholdChildJoinCode delegates.
  • lib/inside/blocs/household/members_bloc.dart + members_state.dart — pending-children list + approve/decline events.
  • lib/inside/i18n/strings... — child-signup, waiting, approval, and typed code-error copy.

Layer 2:

  • infra/supabase/functions/expire-pending-children/index.ts (NEW) — service-role 7-day sweep.
  • app notification surface + tier-varying ConsentMethod selection.

Layer ordering & shippability

LAYER 1 (L1-T1 … L1-T11, plus L1-T7b) is independently shippable. After L1-T11 a child can enter a parent's code, sign up with username + password (the child-auth Edge Function from L1-T7b mints their pre-confirmed synthetic-email auth user), and be frozen at UI + SDK + RLS until a parental member completes the VPC agreement (activation) or declines (deletion) — proven by the SQL-smoke freeze/activation live verification. L1-T7b is authored between L1-T7 and L1-T8 because the child signup screen (L1-T8, create) and the decline path (L1-T7, delete) both depend on it. LAYER 2 (L2-T1 … L2-T4) adds notification, the auto-expiry sweep, tier-varying VPC strength, and decline-UX niceties, and does not block Layer 1.

Migration apply order (by filename — the controller applies in this order at L1-T11):

  1. 20260712000100_member_status_pending_consent.sql (L1-T1)
  2. 20260712000200_link_child_rpc.sql (L1-T2)
  3. 20260712000300_child_consent_rls_freeze.sql (L1-T3)
  4. 20260712000400_decline_child_rpc.sql (L1-T7)

Task authoring order below is pedagogical; file timestamps guarantee correct apply order.


LAYER 1 — Child self-signup + consent gate (independently shippable)

Task L1-T1: MemberStatus.pendingConsent — SDK model + Drift + cloud codec + status CHECK migration

Files:

  • Modify: packages/client_sdk/lib/src/models/member_status.dart
  • Modify: packages/client_sdk/lib/src/models/household_member.dart (the exhaustive hasAccess switch)
  • Create: infra/supabase/migrations/20260712000100_member_status_pending_consent.sql
  • Test: packages/client_sdk/test/models_round_trip_test.dart (add a pendingConsent round-trip)
  • Create: packages/client_sdk/test/models/member_status_pending_consent_test.dart

Interfaces:

  • Produces: MemberStatus.pendingConsent with wireName == 'pendingConsent' and MemberStatus.fromWireName('pendingConsent') == MemberStatus.pendingConsent. The cloud codec (supabase_households.dart status: MemberStatus.fromWireName(r['status']) / 'status': m.status.wireName) and the Drift codec (local_storage_adapter.dart same pair) round-trip it with NO signature change — they already delegate to wireName/fromWireName. HouseholdMemberAccessX.hasAccess gains a pendingConsent => false arm (frozen child has no standing access).

Note: the Drift status column is untyped text; adding an enum value needs no Drift schema bump. The Postgres CHECK is the only DB change, shipped as the migration file.

  • Step 1: Write the failing enum + gate tests

Create packages/client_sdk/test/models/member_status_pending_consent_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('MemberStatus.pendingConsent', () {
test('wireName is the camelCase constant name', () {
expect(MemberStatus.pendingConsent.wireName, 'pendingConsent');
});

test('round-trips through fromWireName', () {
expect(MemberStatus.fromWireName('pendingConsent'),
MemberStatus.pendingConsent);
});

test('every status wire value round-trips', () {
for (final s in MemberStatus.values) {
expect(MemberStatus.fromWireName(s.wireName), s);
}
});
});

group('hasAccess', () {
HouseholdMember member(MemberStatus status) => HouseholdMember(
id: 'm1',
householdId: 'h1',
displayName: 'Kid',
kind: MemberKind.child,
status: status,
);

test('a pendingConsent child has NO standing access (frozen)', () {
expect(member(MemberStatus.pendingConsent).hasAccess(DateTime(2026)),
isFalse);
});

test('an active member has access', () {
expect(member(MemberStatus.active).hasAccess(DateTime(2026)), isTrue);
});
});
}
  • Step 2: Run it to confirm it fails

Run: cd packages/client_sdk && fvm flutter test test/models/member_status_pending_consent_test.dart -p vm Expected: FAIL — pendingConsent is undefined; and once added, the hasAccess switch does not compile until its arm is added.

  • Step 3: Add the enum value

In packages/client_sdk/lib/src/models/member_status.dart, add the constant after active and the fromWireName arm:

enum MemberStatus {
@JsonValue('shadow')
shadow,

@JsonValue('invited')
invited,

@JsonValue('active')
active,

/// A child who has linked their OWN login (username + password) but whose
/// household has not yet completed VPC. Distinct from [shadow] (loginless
/// parent-managed placeholder) and [active] (live). Frozen at UI + SDK + RLS
/// until [ConsentState.granted]; deleted on decline / 7-day expiry.
@JsonValue('pendingConsent')
pendingConsent;

/// The wire/database value (matches the SQL CHECK constraint).
String get wireName => name;

static MemberStatus fromWireName(String value) => switch (value) {
'shadow' => shadow,
'invited' => invited,
'active' => active,
'pendingConsent' => pendingConsent,
_ => throw ArgumentError.value(value, 'value', 'Unknown MemberStatus'),
};
}
  • Step 4: Extend the exhaustive hasAccess switch

In packages/client_sdk/lib/src/models/household_member.dart, in HouseholdMemberAccessX.hasAccess, add the arm (a frozen child has no standing access — access resumes only on activation, which flips status to active):

bool hasAccess(DateTime now) => switch (status) {
MemberStatus.active => true,
MemberStatus.invited => !isInviteExpired(now),
MemberStatus.shadow => false,
MemberStatus.pendingConsent => false,
};
  • Step 5: Add the round-trip assertion

In packages/client_sdk/test/models_round_trip_test.dart, add MemberStatus.pendingConsent wherever the test enumerates member statuses (VERIFY with graphify query "models_round_trip_test MemberStatus"; if it iterates MemberStatus.values the coverage is automatic — then just confirm it still passes).

  • Step 6: Run the tests — green

Run: cd packages/client_sdk && fvm flutter test test/models/member_status_pending_consent_test.dart test/models_round_trip_test.dart -p vm Expected: PASS.

  • Step 7: Write the status-CHECK migration file

Create infra/supabase/migrations/20260712000100_member_status_pending_consent.sql:

-- MemberStatus.pendingConsent (child self-signup, spec decision 4). A child who
-- has linked their own login but whose household has not yet completed VPC. The
-- status domain was added in 20260612000007 as an INLINE unnamed check
-- (auto-named household_members_status_check) over ('shadow','invited','active').
-- Widen it to admit 'pendingConsent'. The Drift/local twin needs no change (its
-- status column is untyped text). Wire string is the enum constant name
-- ('pendingConsent'), matching MemberStatus.wireName.
-- ADDITIVE (widen a CHECK). Not applied here — the controller applies + smokes.

alter table public.household_members
drop constraint household_members_status_check;

alter table public.household_members
add constraint household_members_status_check
check (status in ('shadow', 'invited', 'active', 'pendingConsent'));

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style)
-- 1. \d household_members -> EXPECT household_members_status_check now lists
-- 'pendingConsent'.
-- 2. as service-role: insert a throwaway member with status='pendingConsent'
-- -> EXPECT ok; then delete it.
-- 3. as service-role: insert with status='bogus' -> EXPECT check violation.
-- 4. advisors sweep: no NEW findings.
  • Step 8: Update graphify + commit
cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/lib/src/models/member_status.dart packages/client_sdk/lib/src/models/household_member.dart packages/client_sdk/test/models/member_status_pending_consent_test.dart packages/client_sdk/test/models_round_trip_test.dart infra/supabase/migrations/20260712000100_member_status_pending_consent.sql
git commit -m "feat(sdk,schema): add MemberStatus.pendingConsent + widen status CHECK"

Files:

  • Create: infra/supabase/migrations/20260712000200_link_child_rpc.sql

Interfaces:

  • Consumes: household_members.invite_token_hash (the per-child ATTACH code, hash-stored, landed by the invite feature 20260711000100), MemberStatus.pendingConsent (L1-T1), extensions.digest (pgcrypto).
  • Produces: column public.households.child_join_code_hash text + partial-unique index; Postgres function public.link_child(p_code text, p_display_name text) returns jsonb. Returns {"ok":true,"household_id":"<uuid>","member_id":"<uuid>","path":"attach"|"new"} on success, or {"ok":false,"reason":"<reason>"} where reason ∈ invalid_code | expired | already_linked | not_a_child. The SDK (L1-T4) reads this jsonb; domain failures are RETURNED, not raised.

This ships a migration FILE only. Verification is (a) the embedded LIVE SMOKE PROBES the controller runs after apply and (b) the load-bearing SQL smoke in L1-T11.

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260712000200_link_child_rpc.sql:

-- Child self-signup link RPC (spec §Schema, sibling to accept_invite). A child
-- creates their OWN auth account (username+password, #155) then presents a code
-- the parent controls. This SECURITY DEFINER RPC — the definer-eyes atomic
-- link — validates the code and either:
-- * ATTACH PATH: the code hash matches a pre-created shadow kind='child' row
-- (per-child code stored in invite_token_hash) -> link auth_user_id, flip
-- status shadow -> 'pendingConsent', clear the code. The parent already set
-- the child's age when creating the shadow row.
-- * NEW PATH: the code hash matches a household's child_join_code_hash ->
-- INSERT a fresh kind='child' member directly in 'pendingConsent' (age is
-- set by the parent at the approval step).
-- Binding = THE CODE ITSELF (there is no email to bind to — this is deliberately
-- NOT the email-bound accept_invite path). COPPA data-minimization: the new-path
-- insert stores only display_name; no traits/wallet/PII.
--
-- House pattern: pinned search_path='public'; revoke from public/anon; grant to
-- authenticated (client-called RPC). DISTINCT TYPED REASONS returned as jsonb
-- {ok:false, reason:...} (never raise -> the SDK MappingPort would flatten P0001
-- to a generic StorageFailure). reasons: invalid_code | expired | already_linked
-- | not_a_child.
-- ADDITIVE ONLY (one column + index + function). Not applied here — the
-- controller applies + smokes after review.

create extension if not exists pgcrypto with schema extensions;

-- Household-level child join code (hash-stored, single active code per household).
alter table public.households add column if not exists child_join_code_hash text;
create unique index if not exists households_child_join_code_hash_uidx
on public.households (child_join_code_hash)
where child_join_code_hash is not null;

create or replace function public.link_child(p_code text, p_display_name text)
returns jsonb
language plpgsql security definer set search_path = 'public' as $$
declare
v_uid uuid := auth.uid();
v_hash text;
m record;
h record;
v_member_id uuid;
begin
-- Unauthenticated callers cannot link (revoke-from-anon backs this up). The
-- child MUST have created + be signed into their own auth account first.
if v_uid is null then
return jsonb_build_object('ok', false, 'reason', 'invalid_code');
end if;

v_hash := encode(extensions.digest(p_code, 'sha256'), 'hex');

-- ── ATTACH PATH: a per-child code on a pre-created shadow child row ──────────
select id, household_id, kind, auth_user_id, status, expires_at
into m
from public.household_members
where invite_token_hash = v_hash
limit 1;
if found then
-- The code must belong to a CHILD row (a co-parent invite token must not be
-- redeemable through the child path).
if m.kind <> 'child' then
return jsonb_build_object('ok', false, 'reason', 'not_a_child');
end if;
if m.expires_at is not null and m.expires_at <= now() then
return jsonb_build_object('ok', false, 'reason', 'expired');
end if;
if m.auth_user_id is not null then
return jsonb_build_object('ok', false, 'reason', 'already_linked');
end if;
update public.household_members
set auth_user_id = v_uid,
status = 'pendingConsent',
invite_token_hash = null,
expires_at = null
where id = m.id;
return jsonb_build_object('ok', true, 'household_id', m.household_id,
'member_id', m.id, 'path', 'attach');
end if;

-- ── NEW PATH: a household-level child join code ──────────────────────────────
select id into h
from public.households
where child_join_code_hash = v_hash
limit 1;
if found then
-- Idempotency / single-use per account: if this account already has a row in
-- the household, treat as already linked.
if exists (
select 1 from public.household_members hm
where hm.household_id = h.id and hm.auth_user_id = v_uid
) then
return jsonb_build_object('ok', false, 'reason', 'already_linked');
end if;
insert into public.household_members
(household_id, display_name, kind, roles, status, auth_user_id, consent_state)
values (h.id,
coalesce(nullif(trim(p_display_name), ''), 'New member'),
'child', '{}'::text[], 'pendingConsent', v_uid, 'none')
returning id into v_member_id;
return jsonb_build_object('ok', true, 'household_id', h.id,
'member_id', v_member_id, 'path', 'new');
end if;

-- No attach row, no household code matched.
return jsonb_build_object('ok', false, 'reason', 'invalid_code');
end $$;

revoke execute on function public.link_child(text, text) from public, anon;
grant execute on function public.link_child(text, text) to authenticated;

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style)
-- Fixture: household H (owner/admin A). A pre-creates a shadow child row CR
-- (kind='child', status='shadow', age set, invite_token_hash =
-- encode(extensions.digest('attach-<run>','sha256'),'hex')). Also set
-- households.child_join_code_hash = encode(digest('join-<run>','sha256'),'hex').
-- Child auth identity C is pre-provisioned + email-confirmed out of band.
--
-- 1. ATTACH ok: as C, select public.link_child('attach-<run>','') -> EXPECT
-- {ok:true, path:'attach', member_id:CR}. Then CR is auth_user_id=C,
-- status='pendingConsent', invite_token_hash IS NULL.
-- 2. ATTACH re-use: as C again -> EXPECT {ok:false, reason:'already_linked'}
-- (hash cleared -> now falls through; a fresh C2 on the same cleared code ->
-- invalid_code).
-- 3. NOT A CHILD: seed a co_parent invited row with a known token; as a fresh
-- auth identity call link_child(that raw token,'') -> EXPECT
-- {ok:false, reason:'not_a_child'}.
-- 4. EXPIRED: re-seed CR2 (kind=child, expires_at=now()-'1 day'); as C3 ->
-- EXPECT {ok:false, reason:'expired'}.
-- 5. NEW ok: as a fresh auth identity D, link_child('join-<run>','Robin') ->
-- EXPECT {ok:true, path:'new'}; a fresh kind='child' row exists with
-- display_name='Robin', status='pendingConsent', consent_state='none',
-- auth_user_id=D, and NO traits/wallet rows (data-minimization).
-- 6. NEW re-use (same account): as D again -> EXPECT
-- {ok:false, reason:'already_linked'}.
-- 7. UNKNOWN: as C4, link_child('no-such','') -> EXPECT
-- {ok:false, reason:'invalid_code'}.
-- 8. UNAUTH: with no JWT, link_child('attach-<run>','') -> EXPECT
-- {ok:false, reason:'invalid_code'}.
-- 9. advisors sweep: no NEW findings (pinned search_path + revoked-from-anon +
-- granted-to-authenticated).
-- 10. cleanup (service-role): delete probe members + reset child_join_code_hash.
  • Step 2: Commit
git add infra/supabase/migrations/20260712000200_link_child_rpc.sql
git commit -m "feat(schema): link_child SECURITY DEFINER RPC + household child join-code column"

Task L1-T3: RLS freeze — tighten member_household_ids() + narrow self-row read (migration file)

Files:

  • Create: infra/supabase/migrations/20260712000300_child_consent_rls_freeze.sql

Interfaces:

  • Consumes: household_members.consent_state (added 20260626000001), kind (20260612000001), MemberStatus.pendingConsent (L1-T1).
  • Produces: a redefined public.member_household_ids() that EXCLUDES a kind='child' row whose consent_state <> 'granted', plus a new permissive SELECT policy household_members_self_read that lets any caller read ONLY their own member row(s). Net effect: a pendingConsent (or revoked) child can SELECT/INSERT/UPDATE nothing household-scoped except reading its own member row's status. This is the load-bearing security change — the SDK gate + UI lock are the other two of the triple freeze.

Why member_household_ids() is the single load-bearing helper: households_select and household_members_select both gate on it; parental_household_ids() already excludes children (kind filter); admin_/helper_/owner_household_ids() already exclude a pendingConsent child via their status='active' (and kind/role) filters. So tightening member_household_ids() (and adding the self-row escape hatch) freezes the child everywhere while leaving adults + loginless shadow children untouched (shadow children have auth_user_id IS NULL, so they never matched this helper anyway).

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260712000300_child_consent_rls_freeze.sql:

-- The child-consent RLS FREEZE (spec §Schema, the load-bearing security change).
-- A pendingConsent child IS a member (household_id present, auth_user_id linked),
-- so the base membership helper member_household_ids() would treat it as a full
-- member and every household-scoped SELECT/INSERT/UPDATE policy that gates on it
-- would admit the unconsented child. Gate on the CONSENT dimension, not just
-- membership (same lesson as the invite capability guard): exclude a kind='child'
-- row whose consent_state <> 'granted'. Adults (kind in parent/co_parent) are
-- unaffected; loginless shadow children have auth_user_id IS NULL so never
-- matched; a granted child is admitted exactly as before. The one thing a frozen
-- child MUST still read is its own member row's status (to poll "am I approved
-- yet?"), granted by a narrow self-scoped SELECT policy.
--
-- parental_household_ids() already excludes children (kind filter);
-- admin_/helper_/owner_household_ids() already require status='active' — so
-- member_household_ids() is the single helper that needs tightening.
-- CHANGES member_household_ids() (a redefinition) + adds one SELECT policy.
-- Reversible (restore the prior body; drop the policy). Not applied here — the
-- controller applies + smokes after review.

create or replace function public.member_household_ids()
returns setof uuid
language sql
stable
security definer
set search_path = public
as $$
select household_id from public.household_members
where auth_user_id = auth.uid()
-- Freeze: a child counts as a member only once VPC is granted.
and not (kind = 'child' and consent_state is distinct from 'granted');
$$;

-- Narrow self-row read: a FROZEN child (excluded from member_household_ids())
-- must still read its own member row to poll status. This is a SECOND permissive
-- SELECT policy (OR-combined with household_members_select). It exposes exactly
-- the caller's own row(s) and nothing else — for adults it is a harmless
-- redundant path; for a frozen child it is the ONLY row it can read.
create policy household_members_self_read on public.household_members
for select to authenticated
using (auth_user_id = auth.uid());

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style; these overlap the
-- L1-T11 SQL smoke but are run standalone right after apply)
-- Fixture: household H with owner A (adult), a granted child GC
-- (kind='child', consent_state='granted', auth_user_id=GCU), and a frozen child
-- FC (kind='child', consent_state='none', status='pendingConsent',
-- auth_user_id=FCU). Simulate JWTs with set_config('request.jwt.claims', ...).
-- 1. as A: select member_household_ids() -> EXPECT contains H (adult unaffected).
-- 2. as GCU: select member_household_ids() -> EXPECT contains H (granted child
-- admitted).
-- 3. as FCU: select member_household_ids() -> EXPECT does NOT contain H (frozen).
-- 4. as FCU: select * from household_members where household_id = H
-- -> EXPECT exactly 1 row (FC's own, via household_members_self_read); it can
-- read its own status but no sibling/parent rows.
-- 5. as FCU: select * from households where id = H -> EXPECT 0 rows (frozen out
-- of households_select).
-- 6. as FCU: insert/update any household-scoped row -> EXPECT RLS denial.
-- 7. advisors sweep: no NEW findings (both SELECT policies present; helper still
-- security definer + pinned search_path).
  • Step 2: Commit
git add infra/supabase/migrations/20260712000300_child_consent_rls_freeze.sql
git commit -m "feat(schema): RLS freeze — exclude non-granted children from member_household_ids + self-row read"

Files:

  • Modify: packages/client_sdk/lib/src/models/exceptions.dart (add ChildLinkRejectionReason + ChildLinkException)
  • Create: packages/client_sdk/lib/src/services/child_signup_service.dart
  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (StoragePort: add linkChildRemote, declineChildRemote)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (linkChildRemote/declineChildRemote via db.rpc + reason mapper)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart (cloud-only stubs)
  • Modify: packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart (forwarders)
  • Modify: packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart (cloud-only stubs)
  • Modify: packages/client_sdk/lib/src/client/client.dart + client_impl.dart + create_client.dart (facade wiring, useRemoteChildLink)
  • Modify: packages/client_sdk/lib/client_sdk.dart (barrel exports)
  • Modify: packages/client_sdk/test/support/fake_port.dart (stubs)
  • Create: packages/client_sdk/test/services/child_signup_service_test.dart

Interfaces:

  • Consumes: PostgrestPort.rpc(String fn, Map<String,dynamic> params) + hashInviteToken (invite feature), StoragePort.memberByAuthUserId, StoragePort.getMembers, StoragePort.getHousehold, StoragePort.insertMember/updateMember, TokenGenerator/IdGenerator, Authorizer + Capability (parental gating).

  • Produces (later tasks rely on these EXACT signatures):

    • enum ChildLinkRejectionReason { invalidCode, expired, alreadyLinked, notAChild }.
    • class ChildLinkException extends DomainRuleException { const ChildLinkException(super.message, {required this.reason}); final ChildLinkRejectionReason reason; }.
    • StoragePort.linkChildRemote({required String code, required String displayName}) → Future<String> (returns household id; throws ChildLinkException on a typed reason). Local adapters throw UnimplementedError (child self-signup is a cloud concern).
    • StoragePort.declineChildRemote({required String memberId}) → Future<void> (used by L1-T7). Its cloud impl calls decline_child (member-row delete) then deleteChildAuthRemote(...) (auth.users delete via the child-auth Edge Function) — deleteChildAuthRemote + provisionChildAuthRemote + the db.invokeFunctionResult seam are introduced in L1-T7b; author L1-T7b before wiring this call, or stub deleteChildAuthRemote to UnimplementedError until L1-T7b lands.
    • class ChildSignupService with:
      • Future<HouseholdMember> signUpChild({required String code, required String displayName, required String authUserId}) — CLOUD: linkChildRemote then memberByAuthUserId(authUserId) (readable via the self-row policy); LOCAL/in-memory: direct parity path with the same validations, throwing ChildLinkException.
      • Future<HouseholdMember> issueChildAttachCode({required String actingMemberId, required String memberId, Duration validFor = const Duration(days: 14)}) — parental-gated; sets invite_token_hash = hashInviteToken(raw) on a shadow kind=child row and returns the member with the RAW code in inviteToken (one-time display).
      • Future<String> issueHouseholdChildJoinCode({required String actingMemberId}) — parental-gated; sets households.child_join_code_hash and returns the RAW code once (cloud updates the household row; in-memory parity stores it).
  • Step 1: Add the typed exception — write the failing test

Create packages/client_sdk/test/services/child_signup_service_test.dart with the local-parity group first:

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/services/child_signup_service.dart';
import 'package:client_sdk/src/services/id_generator.dart';
import 'package:flutter_test/flutter_test.dart';

import '../support/fake_port.dart';

/// Deterministic tokens so the parent-issued raw code is predictable.
class _SeqTokens implements TokenGenerator {
int _i = 0;
@override
String next() => 'code-${++_i}';
}

void main() {
late FakePort port;
late ChildSignupService service;

setUp(() async {
port = FakePort();
await port.insertHousehold(const Household(id: 'h1', name: 'Casa'));
// Owner parent A (linked auth).
await port.insertMember(const HouseholdMember(
id: 'a', householdId: 'h1', displayName: 'A',
kind: MemberKind.parent, status: MemberStatus.active,
roles: {MemberRole.admin}, owner: true, authUserId: 'auth-a',
));
// Pre-created shadow child CR (age set by the parent).
await port.insertMember(const HouseholdMember(
id: 'cr', householdId: 'h1', displayName: 'Kiddo',
kind: MemberKind.child, status: MemberStatus.shadow, age: 8,
));
service = ChildSignupService(
storage: port,
tokenGenerator: _SeqTokens(),
useRemoteChildLink: false,
);
});

test('issueChildAttachCode hash-stores the code and returns the raw once',
() async {
final issued = await service.issueChildAttachCode(
actingMemberId: 'a', memberId: 'cr');
expect(issued.inviteToken, 'code-1'); // returned RAW
expect(port.members['cr']!.inviteToken, hashInviteToken('code-1'));
});

test('signUpChild attaches the login and freezes at pendingConsent', () async {
await service.issueChildAttachCode(actingMemberId: 'a', memberId: 'cr');
final linked = await service.signUpChild(
code: 'code-1', displayName: 'Kiddo', authUserId: 'auth-kid');
expect(linked.id, 'cr');
expect(linked.status, MemberStatus.pendingConsent);
expect(linked.authUserId, 'auth-kid');
expect(linked.consentState, ConsentState.none);
expect(port.members['cr']!.inviteToken, isNull); // single-use, cleared
});

test('an unknown code is a typed invalidCode rejection', () async {
await expectLater(
() => service.signUpChild(
code: 'nope', displayName: 'x', authUserId: 'auth-x'),
throwsA(isA<ChildLinkException>().having(
(e) => e.reason, 'reason', ChildLinkRejectionReason.invalidCode)),
);
});

test('a code pointing at a NON-child row is a notAChild rejection', () async {
// Give the parent row a code, then try to redeem it via the child path.
await port.updateMember(port.members['a']!
.copyWith(setInviteToken: () => hashInviteToken('adult-code')));
await expectLater(
() => service.signUpChild(
code: 'adult-code', displayName: 'x', authUserId: 'auth-y'),
throwsA(isA<ChildLinkException>().having(
(e) => e.reason, 'reason', ChildLinkRejectionReason.notAChild)),
);
});

test('re-redeeming a cleared code is alreadyLinked/invalid, never re-attaches',
() async {
await service.issueChildAttachCode(actingMemberId: 'a', memberId: 'cr');
await service.signUpChild(
code: 'code-1', displayName: 'Kiddo', authUserId: 'auth-kid');
await expectLater(
() => service.signUpChild(
code: 'code-1', displayName: 'Kiddo', authUserId: 'auth-2'),
throwsA(isA<ChildLinkException>()),
);
});
}
  • Step 2: Run it to confirm it fails

Run: cd packages/client_sdk && fvm flutter test test/services/child_signup_service_test.dart -p vm Expected: FAIL — ChildSignupService, ChildLinkException, ChildLinkRejectionReason, and the StoragePort members are undefined.

  • Step 3: Add the typed exception

Append to packages/client_sdk/lib/src/models/exceptions.dart:

/// Why a [ChildLinkException] was raised — lets the child-signup UI pick specific
/// copy without inspecting the message. Mirrors the `reason` the `link_child` RPC
/// returns (invalid_code/expired/already_linked/not_a_child) and the local-parity
/// checks in [ChildSignupService.signUpChild].
enum ChildLinkRejectionReason {
/// No shadow child row and no household join code matches the presented code.
invalidCode,

/// The code matched an attach row whose window has passed (`expires_at <= now`).
expired,

/// The matched row is already linked to an account (single-use spent), or this
/// account already has a row in the household.
alreadyLinked,

/// The code matched a member row that is NOT `kind = child` (e.g. a co-parent
/// invite token presented to the child path).
notAChild,
}

/// Thrown when a child self-signup link is rejected for a domain reason. Extends
/// [DomainRuleException] so existing `on Exception`/`on DomainRuleException`
/// catches still work; [reason] carries the machine-readable cause for UI copy.
class ChildLinkException extends DomainRuleException {
const ChildLinkException(super.message, {required this.reason});

final ChildLinkRejectionReason reason;

@override
String toString() => 'ChildLinkException($reason): $message';
}
  • Step 4: Extend StoragePort

In packages/client_sdk/lib/src/adapters/adapter.dart, add to abstract class StoragePort (Members section, after deleteMember):

/// Cloud-only atomic child link (spec §Schema). Presents the RAW [code] +
/// [displayName] to the `link_child` SECURITY DEFINER RPC, which validates +
/// links/creates under definer rights and returns the household id. Throws a
/// [ChildLinkException] carrying the RPC's typed reason on rejection. Local
/// adapters do NOT implement this — child self-signup is a cloud concern; the
/// in-memory fake path lives in ChildSignupService for parity tests.
Future<String> linkChildRemote({
required String code,
required String displayName,
});

/// Cloud-only decline (spec decision 7). Presents [memberId] to the
/// `decline_child` SECURITY DEFINER RPC, which (parental-gated) deletes the
/// pending member row + the child `auth.users` account. Local adapters throw.
Future<void> declineChildRemote({required String memberId});
  • Step 5: Implement the cloud adapter methods (Households mixin)

In packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart, add the import import '../../models/exceptions.dart'; if absent, and inside mixin Households (after acceptInviteRemote / deleteMember):

Future<String> linkChildRemote({
required String code,
required String displayName,
}) async {
final result = await db.rpc(
'link_child',
{'p_code': code, 'p_display_name': displayName},
);
if (result['ok'] == true) {
return result['household_id'] as String;
}
throw _childLinkRejection(result['reason'] as String?);
}

Future<void> declineChildRemote({required String memberId}) async {
// Member-row deletion is the definer RPC's job; auth.users deletion is the
// service-role child-auth Edge Function's job (dual-owner split — see
// L1-T7b). decline_child returns the child's auth_user_id + household_id.
final result = await db.rpc('decline_child', {'p_member_id': memberId});
if (result['ok'] != true) {
throw StorageFailure(
'Could not decline this pending child (${result['reason']}).');
}
final authUserId = result['auth_user_id'] as String?;
if (authUserId != null) {
await deleteChildAuthRemote(
authUserId: authUserId,
householdId: result['household_id'] as String?,
);
}
}

ChildLinkException _childLinkRejection(String? reason) => switch (reason) {
'expired' => const ChildLinkException('This code has expired.',
reason: ChildLinkRejectionReason.expired),
'already_linked' => const ChildLinkException(
'This code has already been used.',
reason: ChildLinkRejectionReason.alreadyLinked),
'not_a_child' => const ChildLinkException(
'This code is not a child sign-up code.',
reason: ChildLinkRejectionReason.notAChild),
_ => const ChildLinkException('That code is not valid.',
reason: ChildLinkRejectionReason.invalidCode),
};
  • Step 6: Implement the local/cached/in-memory stubs + fake

In in_memory_storage_adapter.dart and local_storage_adapter.dart, add:

@override
Future<String> linkChildRemote({
required String code,
required String displayName,
}) =>
throw UnimplementedError(
'linkChildRemote is cloud-only; local parity lives in '
'ChildSignupService.signUpChild');

@override
Future<void> declineChildRemote({required String memberId}) =>
throw UnimplementedError('declineChildRemote is cloud-only');

In cached_storage_adapter.dart, add forwarders to _durable (hydrate the joined household after a link so the follow-up member read hits a warm cache):

@override
Future<String> linkChildRemote({
required String code,
required String displayName,
}) async {
await _ensureHydrated();
final householdId =
await _durable.linkChildRemote(code: code, displayName: displayName);
await _hydrate(householdId: householdId);
return householdId;
}

@override
Future<void> declineChildRemote({required String memberId}) =>
_durable.declineChildRemote(memberId: memberId);

In packages/client_sdk/test/support/fake_port.dart, add stubs (the in-memory parity path never calls these — useRemoteChildLink is false in unit tests):

@override
Future<String> linkChildRemote({
required String code,
required String displayName,
}) =>
throw UnimplementedError('linkChildRemote is cloud-only in tests');

@override
Future<void> declineChildRemote({required String memberId}) async {
members.remove(memberId);
}
  • Step 7: Write the service

Create packages/client_sdk/lib/src/services/child_signup_service.dart:

import '../adapters/adapter.dart';
import '../models/capability.dart';
import '../models/consent_state.dart';
import '../models/exceptions.dart';
import '../models/household.dart';
import '../models/household_member.dart';
import '../models/member_kind.dart';
import '../models/member_status.dart';
import 'authorizer.dart';
import 'id_generator.dart';

/// Child self-signup domain service (spec §SDK). Owns BOTH sides of the
/// consent-gated child entry: the parent ISSUES a code (per-child attach code or
/// a household join code) and READS/DECLINES pending children; the child REDEEMS
/// a code to obtain a `pendingConsent` member. Cloud routes the redeem through
/// the `link_child` SECURITY DEFINER RPC; local/in-memory keeps a direct parity
/// path with the same validations. Activation is NOT here — it is the existing
/// [ConsentService.captureConsent] (which flips `pendingConsent -> active`).
class ChildSignupService {
ChildSignupService({
required StoragePort storage,
IdGenerator? idGenerator,
TokenGenerator? tokenGenerator,
DateTime Function()? now,
Authorizer? authorizer,
bool useRemoteChildLink = false,
}) : _storage = storage,
_ids = idGenerator ?? const IdGenerator(),
_tokens = tokenGenerator ?? const TokenGenerator(),
_now = now ?? DateTime.now,
_authorizer = authorizer ?? const Authorizer(),
_useRemoteChildLink = useRemoteChildLink;

final StoragePort _storage;
final IdGenerator _ids;
final TokenGenerator _tokens;
final DateTime Function() _now;
final Authorizer _authorizer;
final bool _useRemoteChildLink;

/// The child redeems [code] to link/create their own `pendingConsent` member.
/// CLOUD: the `link_child` RPC (authoritative); then re-read the child's own
/// row (readable via the self-row RLS policy). LOCAL/in-memory: the direct
/// parity twin (dual gate). Throws [ChildLinkException] with a typed reason.
Future<HouseholdMember> signUpChild({
required String code,
required String displayName,
required String authUserId,
}) async {
if (code.trim().isEmpty) {
throw const ChildLinkException('Enter a code.',
reason: ChildLinkRejectionReason.invalidCode);
}
if (_useRemoteChildLink) {
await _storage.linkChildRemote(code: code, displayName: displayName);
final member = await _storage.memberByAuthUserId(authUserId);
if (member == null) {
throw const StorageFailure(
'Signed up, but could not load your profile. Please reopen the app.');
}
return member;
}

// LOCAL parity twin of link_child (dual gate).
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
final hash = hashInviteToken(code);
// Attach path: a per-child code on a shadow child row.
final attach = members
.where((m) => m.inviteToken != null && m.inviteToken == hash)
.firstOrNull;
if (attach != null) {
if (attach.kind != MemberKind.child) {
throw const ChildLinkException('This code is not a child sign-up code.',
reason: ChildLinkRejectionReason.notAChild);
}
final now = _now();
if (attach.expiresAt != null && attach.expiresAt!.isBefore(now)) {
throw const ChildLinkException('This code has expired.',
reason: ChildLinkRejectionReason.expired);
}
if (attach.authUserId != null) {
throw const ChildLinkException('This code has already been used.',
reason: ChildLinkRejectionReason.alreadyLinked);
}
return _storage.updateMember(attach.copyWith(
status: MemberStatus.pendingConsent,
setAuthUserId: () => authUserId,
setInviteToken: () => null,
setExpiresAt: () => null,
));
}
// New path: a household join code (in-memory stores it on the household).
if (household.childJoinCodeHash == hash) {
if (members.any((m) => m.authUserId == authUserId)) {
throw const ChildLinkException('This code has already been used.',
reason: ChildLinkRejectionReason.alreadyLinked);
}
return _storage.insertMember(HouseholdMember(
id: _ids.next(),
householdId: household.id,
displayName: displayName.trim().isEmpty ? 'New member' : displayName.trim(),
kind: MemberKind.child,
status: MemberStatus.pendingConsent,
createdAt: _now(),
));
}
throw const ChildLinkException('That code is not valid.',
reason: ChildLinkRejectionReason.invalidCode);
}

/// Parent issues a per-child ATTACH code on a pre-created shadow child row.
/// Parental-gated. Hash-stores the code (same convention as invites) and
/// returns the member with the RAW code in [HouseholdMember.inviteToken]
/// (one-time display).
Future<HouseholdMember> issueChildAttachCode({
required String actingMemberId,
required String memberId,
Duration validFor = const Duration(days: 14),
}) async {
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
_requireCapability(members, actingMemberId, Capability.manageMembers);
final target = members.where((m) => m.id == memberId).firstOrNull;
if (target == null) {
throw const DomainRuleException('Member not found.');
}
if (target.kind != MemberKind.child) {
throw const DomainRuleException('Attach codes are for child members only.');
}
if (target.authUserId != null) {
throw const DomainRuleException('This child already has a linked login.');
}
final now = _now();
final raw = _tokens.next();
final stored = await _storage.updateMember(target.copyWith(
setInviteToken: () => hashInviteToken(raw),
setExpiresAt: () => now.add(validFor),
));
return stored.copyWith(setInviteToken: () => raw);
}

/// Parent issues/rotates the HOUSEHOLD child join code. Parental-gated.
/// Returns the RAW code once (hash-stored on the household).
Future<String> issueHouseholdChildJoinCode({
required String actingMemberId,
}) async {
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
_requireCapability(members, actingMemberId, Capability.manageMembers);
final raw = _tokens.next();
await _storage.updateHousehold(
household.copyWith(setChildJoinCodeHash: () => hashInviteToken(raw)),
);
return raw;
}

/// The household's children in `pendingConsent` — the approval-surface read.
/// A parental caller sees them (their own household read returns every row;
/// the freeze only bites the CHILD's own reads).
Future<List<HouseholdMember>> pendingConsentChildren() async {
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
return members
.where((m) =>
m.kind == MemberKind.child &&
m.status == MemberStatus.pendingConsent)
.toList(growable: false);
}

/// Decline a pending child — deletes the member row + child auth account
/// (COPPA). CLOUD: the `decline_child` RPC (parental-gated at RLS too). LOCAL:
/// a direct delete (parental-gated here).
Future<void> declineChild({
required String actingMemberId,
required String memberId,
}) async {
if (_useRemoteChildLink) {
await _storage.declineChildRemote(memberId: memberId);
return;
}
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
_requireCapability(members, actingMemberId, Capability.manageMembers);
await _storage.deleteMember(memberId);
}

HouseholdMember _requireCapability(
List<HouseholdMember> members,
String actingMemberId,
Capability capability,
) {
final actor = members.where((m) => m.id == actingMemberId).firstOrNull;
if (actor == null) {
throw const DomainRuleException('Acting member not found in this household.');
}
_authorizer.require(actor, capability, now: _now());
return actor;
}

Future<Household> _requireHousehold() async {
final household = await _storage.getHousehold();
if (household == null) {
throw const DomainRuleException('No household exists yet.');
}
return household;
}
}

VERIFY the exact Capability constant for member management with graphify query "Capability manageMembers inviteMember enum values" — use whatever the invite/member surface already gates on (e.g. Capability.manageMembers / Capability.inviteMember); the parent code-issuance + decline must reuse the same parental capability, not invent one. VERIFY that Household has (or add) a childJoinCodeHash field + copyWith(setChildJoinCodeHash:) and that StoragePort.updateHousehold exists — graphify query "Household model copyWith updateHousehold StoragePort"; if childJoinCodeHash is missing, add it to the Household model + its cloud/Drift codec (cloud column child_join_code_hash from L1-T2; the in-memory/local twin needs the field). This mirrors how HouseholdMember.inviteToken was threaded.

  • Step 8: Wire the facade

Add to packages/client_sdk/lib/src/client/client.dart (abstract Client):

Future<HouseholdMember> signUpChild({
required String code,
required String displayName,
required String authUserId,
});
Future<HouseholdMember> issueChildAttachCode({
required String actingMemberId,
required String memberId,
});
Future<String> issueHouseholdChildJoinCode({required String actingMemberId});
Future<List<HouseholdMember>> pendingConsentChildren();
Future<void> declineChild({
required String actingMemberId,
required String memberId,
});

In client_impl.dart, hold a ChildSignupService _childSignupService; field (constructed in clientFromPort, threading useRemoteChildLink), and delegate each facade method to it. In clientFromPort add bool useRemoteChildLink = false and build ChildSignupService(storage: storage, now: clock, useRemoteChildLink: useRemoteChildLink). In create_client.dart pass useRemoteChildLink: config.dataMode == DataMode.cloud at the cloud return (mirror the useRemoteInviteAccept thread). Export the service surface + exceptions from lib/client_sdk.dart (VERIFY the barrel pattern first).

  • Step 9: Run the service tests — green

Run: cd packages/client_sdk && fvm flutter test test/services/child_signup_service_test.dart -p vm Expected: PASS.

  • Step 10: Add the cloud-routing test

Create packages/client_sdk/test/cloud/link_child_routing_test.dart (mirror accept_invite_routing_test.dart): a FakePostgrest with a programmable rpcResponse, a SupabaseStorageAdapter.forTest(fake), and a ChildSignupService(storage: adapter, useRemoteChildLink: true). Seed the linked child row so the post-RPC memberByAuthUserId read returns it. Assert: (a) rpcResponse = {'ok': true, 'household_id': 'h1'}signUpChild returns the pendingConsent member; (b) rpcResponse = {'ok': false, 'reason': 'not_a_child'} → throws ChildLinkException with reason == ChildLinkRejectionReason.notAChild. Note: the seeded member row must carry every column _member decodes (cross-check the codec; add consent_state, age, etc. as needed — same list as the invite routing test).

  • Step 11: Run the full SDK suite + commit

Run: cd packages/client_sdk && fvm flutter test Expected: PASS, SDK total ≥ 1049 + the new tests.

cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/src/services/child_signup_service.dart packages/client_sdk/lib/src/adapters/adapter.dart packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart packages/client_sdk/lib/src/client/client.dart packages/client_sdk/lib/src/client/client_impl.dart packages/client_sdk/lib/src/client/create_client.dart packages/client_sdk/lib/client_sdk.dart packages/client_sdk/test/support/fake_port.dart packages/client_sdk/test/services/child_signup_service_test.dart packages/client_sdk/test/cloud/link_child_routing_test.dart
git commit -m "feat(sdk): child-signup service (link_child cloud RPC + in-memory parity, typed reasons)"

(Also git add the Household model + codec files if you added childJoinCodeHash, and any regenerated .g.dart. Never stage graphify-out/.)


Task L1-T5: SDK — activation (captureConsent flips pendingConsent → active) + gate covers the self-signup origin

Files:

  • Modify: packages/client_sdk/lib/src/services/consent_service.dart (captureConsent also flips a pendingConsent child to active)
  • Modify: packages/client_sdk/test/services/consent_service_test.dart (activation test)
  • Modify: packages/client_sdk/test/services/child_consent_gate_test.dart (self-signup-origin gate coverage)

Interfaces:

  • Consumes: ConsentService.captureConsent({actingMemberId, memberId, method, tosVersion, privacyVersion}) (unchanged signature), ConsentService.assertChildDataAllowed(member) (unchanged — keys on kind == child, so it already gates a pendingConsent self-signup child).

  • Produces: after captureConsent on a child whose status == pendingConsent, the projected member is consentState: granted AND status: active (activation — the spec's "no new verb"). revokeConsent is unchanged (it sets consentState: revoked, which the RLS freeze + UI routing treat as frozen; it does NOT need a status change — routing keys on consentState).

  • Step 1: Write the failing activation test

Add to packages/client_sdk/test/services/consent_service_test.dart (in the capture group; reuse the file's existing FakePort/FakeConsentPort setup):

test('captureConsent activates a pendingConsent child (granted + active)',
() async {
// Seed a pendingConsent child with a linked login.
await port.updateMember(port.members['kid']!.copyWith(
status: MemberStatus.pendingConsent,
setAuthUserId: () => 'auth-kid',
));
await service.captureConsent(
actingMemberId: 'parent',
memberId: 'kid',
method: ConsentMethod.emailPlus,
tosVersion: kCurrentTosVersion,
privacyVersion: kCurrentPrivacyVersion,
);
final kid = port.members['kid']!;
expect(kid.consentState, ConsentState.granted);
expect(kid.status, MemberStatus.active,
reason: 'activation flips a pendingConsent child to active');
});

VERIFY the seed helper / actor+child ids used by the existing test file (graphify query "consent_service_test FakePort captureConsent seed") and match them.

  • Step 2: Run it — fails

Run: cd packages/client_sdk && fvm flutter test test/services/consent_service_test.dart -p vm --plain-name "activates a pendingConsent child" Expected: FAIL — captureConsent currently projects only consentState; status stays pendingConsent.

  • Step 3: Flip status in the projection

In consent_service.dart, in _projectMemberState, compute the activated status for a granted capture and set it (a pendingConsent child becomes active; every other case is unchanged):

Future<void> _projectMemberState(
HouseholdMember member,
ConsentState state,
ConsentRecord record,
) {
// Activation (spec: no new verb): granting VPC to a child who linked their
// own login (pendingConsent) drops them into normal supervised child mode.
final activatedStatus =
state == ConsentState.granted && member.status == MemberStatus.pendingConsent
? MemberStatus.active
: member.status;
return _storage.updateMember(
member.copyWith(
status: activatedStatus,
consentState: state,
setConsentRef: () => record.id,
setConsentTosVersion: () => record.tosVersion,
setConsentPrivacyVersion: () => record.privacyVersion,
),
);
}

Add the member_status.dart import to consent_service.dart if absent.

Note on the online contract: on the cloud path the sync_member_consent_state trigger owns consent_state; this PATCH echoes it unchanged and additionally sets statusstatus is not a trigger-guarded column, so the PATCH is accepted. Confirm in the L1-T11 SQL smoke that the activation status write is permitted alongside the trigger-set consent_state (probe 3 below).

  • Step 4: Add the self-signup-origin gate test

Add to packages/client_sdk/test/services/child_consent_gate_test.dart (the gate is origin-agnostic — a pendingConsent child is kind == child, so it is already blocked; this test PINS that coverage):

test('a pendingConsent self-signup child is blocked by the gate', () {
const kid = HouseholdMember(
id: 'k', householdId: 'h', displayName: 'Kid',
kind: MemberKind.child, status: MemberStatus.pendingConsent,
authUserId: 'auth-kid', consentState: ConsentState.none,
);
expect(
() => ConsentService.assertChildDataAllowed(kid),
throwsA(isA<ConsentRequired>().having(
(e) => e.reason, 'reason', ConsentRequiredReason.notGranted)),
);
});
  • Step 5: Run the SDK suite — green + commit

Run: cd packages/client_sdk && fvm flutter test Expected: PASS.

cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/lib/src/services/consent_service.dart packages/client_sdk/test/services/consent_service_test.dart packages/client_sdk/test/services/child_consent_gate_test.dart
git commit -m "feat(sdk): captureConsent activates pendingConsent children; gate covers self-signup origin"

Task L1-T6: SDK — pending-child read on the facade

Files:

  • Modify: packages/client_sdk/test/services/child_signup_service_test.dart (pending-children read tests)
  • (facade pendingConsentChildren already added in L1-T4; this task PROVES it end-to-end through the client + repository seam)
  • Modify: packages/client_sdk/test/client/governance_facade_test.dart (or the appropriate facade test — VERIFY) to cover the facade delegate

Interfaces:

  • Consumes: ChildSignupService.pendingConsentChildren() (L1-T4), Client.pendingConsentChildren() (facade, L1-T4).

  • Produces: verified pendingConsentChildren() returns exactly the kind==child && status==pendingConsent roster subset (excludes shadow, active, invited, and non-child members).

  • Step 1: Write the failing read test

Add to child_signup_service_test.dart:

test('pendingConsentChildren returns only pendingConsent children', () async {
// shadow child (not yet linked), active child (already consented), a
// co-parent, and one pendingConsent child.
await port.insertMember(const HouseholdMember(
id: 'active-kid', householdId: 'h1', displayName: 'AK',
kind: MemberKind.child, status: MemberStatus.active,
consentState: ConsentState.granted, authUserId: 'auth-ak'));
await port.insertMember(const HouseholdMember(
id: 'pend', householdId: 'h1', displayName: 'Pending Pat',
kind: MemberKind.child, status: MemberStatus.pendingConsent,
authUserId: 'auth-pend'));
await port.insertMember(const HouseholdMember(
id: 'cp', householdId: 'h1', displayName: 'Co',
kind: MemberKind.coParent, status: MemberStatus.active,
authUserId: 'auth-cp'));

final pending = await service.pendingConsentChildren();
expect(pending.map((m) => m.id), ['pend']);
});
  • Step 2: Run it — should PASS (the read was implemented in L1-T4).

Run: cd packages/client_sdk && fvm flutter test test/services/child_signup_service_test.dart -p vm --plain-name "pendingConsentChildren" Expected: PASS. (If the facade delegate was missed in L1-T4, add it now; the RED here is the compile error if pendingConsentChildren is absent.)

  • Step 3: Add a facade-level delegate test

In the facade test (VERIFY the right file via graphify query "Client facade test governance p1p2 pendingConsentChildren"), construct the in-memory Client and assert client.pendingConsentChildren() returns the same subset. This proves the Bloc→Repository→Client→Service path.

  • Step 4: Run the SDK suite — green + commit

Run: cd packages/client_sdk && fvm flutter test Expected: PASS.

cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/test/services/child_signup_service_test.dart packages/client_sdk/test/client/governance_facade_test.dart
git commit -m "test(sdk): pendingConsentChildren read coverage (service + facade)"

Task L1-T7: decline_child SECURITY DEFINER RPC (migration file) + facade decline coverage

Files:

  • Create: infra/supabase/migrations/20260712000400_decline_child_rpc.sql
  • Modify: packages/client_sdk/test/services/child_signup_service_test.dart (local decline test)
  • Modify: packages/client_sdk/test/cloud/link_child_routing_test.dart (cloud decline routing)

Interfaces:

  • Consumes: parental_household_ids() (RLS helper), MemberStatus.pendingConsent, ChildSignupService.declineChild + StoragePort.declineChildRemote (L1-T4).

  • Produces: public.decline_child(p_member_id uuid) returns jsonb — parental-gated; deletes the pending child member row and RETURNS the linked child's auth_user_id + household_id so the SDK can delete the auth.users row via the child-auth Edge Function (L1-T7b) — the RPC no longer touches auth.users itself (COPPA delete-if-no-consent, split-owner: member row here, auth user in the Edge Function). Returns {"ok":true,"auth_user_id":"<uuid|null>","household_id":"<uuid>"} or {"ok":false,"reason":"not_authorized"|"not_found"|"not_pending_child"}.

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260712000400_decline_child_rpc.sql:

-- Decline a pending child (spec decision 7 + COPPA delete-if-no-consent). A
-- parental (parent/co_parent) member of the child's household declines a
-- pendingConsent child; the RPC deletes the member row and RETURNS the child's
-- auth_user_id + household_id (the only data captured pre-consent was the
-- credential + display name). The child auth.users row is deleted by the
-- service-role child-auth Edge Function (L1-T7b) — NOT here — so this migration
-- does not depend on the migration runner owning auth.users (the original
-- ownership uncertainty is thereby removed). SECURITY DEFINER so the member-row
-- delete + the parental gate run atomically. Parental-gated in the RPC AND —
-- because household_members_delete is parental-only in RLS — the dual gate holds.
-- Domain outcomes RETURNED as jsonb.
--
-- House pattern: pinned search_path='public'; revoke from public/anon; grant to
-- authenticated. ADDITIVE ONLY. Not applied here — the controller applies + smokes.

create or replace function public.decline_child(p_member_id uuid)
returns jsonb
language plpgsql security definer set search_path = 'public' as $$
declare
v_uid uuid := auth.uid();
m record;
begin
if v_uid is null then
return jsonb_build_object('ok', false, 'reason', 'not_authorized');
end if;

select id, household_id, kind, status, auth_user_id
into m from public.household_members where id = p_member_id limit 1;
if not found then
return jsonb_build_object('ok', false, 'reason', 'not_found');
end if;

-- Only a parental member of THIS household may decline.
if m.household_id not in (select public.parental_household_ids()) then
return jsonb_build_object('ok', false, 'reason', 'not_authorized');
end if;

-- Only a pendingConsent child is declinable (never an active member).
if m.kind <> 'child' or m.status <> 'pendingConsent' then
return jsonb_build_object('ok', false, 'reason', 'not_pending_child');
end if;

-- Delete the member row here; the child's auth.users row is deleted by the
-- caller via the child-auth Edge Function (L1-T7b) using the returned ids.
delete from public.household_members where id = m.id;

return jsonb_build_object('ok', true,
'auth_user_id', m.auth_user_id, 'household_id', m.household_id);
end $$;

revoke execute on function public.decline_child(uuid) from public, anon;
grant execute on function public.decline_child(uuid) to authenticated;

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style; simulated JWTs)
-- Fixture: household H, owner A (parental), frozen child FC
-- (kind='child', status='pendingConsent', auth_user_id=FCU).
-- 1. NON-PARENTAL DECLINE: as FCU (the child), decline_child(FC) -> EXPECT
-- {ok:false, reason:'not_authorized'} (FCU not in parental_household_ids()).
-- 2. PARENTAL DECLINE ok: as A, decline_child(FC) -> EXPECT
-- {ok:true, auth_user_id:FCU, household_id:H}; then household_members has no
-- FC row. (auth.users deletion of FCU is the child-auth Edge Function's job,
-- smoked in L1-T7b / the L1-T11 deploy — not this RPC.)
-- 3. ACTIVE NOT DECLINABLE: seed an active child AC; as A, decline_child(AC)
-- -> EXPECT {ok:false, reason:'not_pending_child'} (AC untouched).
-- 4. UNKNOWN id: as A, decline_child(gen_random_uuid()) -> EXPECT
-- {ok:false, reason:'not_found'}.
-- 5. advisors sweep: no NEW findings.
-- 6. cleanup (service-role): remove any residual probe rows.
  • Step 2: Add the local decline test

Add to child_signup_service_test.dart (local mode: declineChild deletes the pending member row after a parental gate):

test('declineChild removes a pending child (local parity)', () async {
await port.insertMember(const HouseholdMember(
id: 'pend', householdId: 'h1', displayName: 'Pat',
kind: MemberKind.child, status: MemberStatus.pendingConsent,
authUserId: 'auth-pend'));
await service.declineChild(actingMemberId: 'a', memberId: 'pend');
expect(port.members.containsKey('pend'), isFalse);
});

Add a cloud-routing decline test to link_child_routing_test.dart: rpcResponse = {'ok': true, 'auth_user_id': 'auth-pend', 'household_id': 'h1'} + a programmed invokeResult('child-auth') = {'ok': true}declineChild completes AND the fake records a child-auth invoke with {action: 'delete', auth_user_id: 'auth-pend', household_id: 'h1'}; rpcResponse = {'ok': false, 'reason': 'not_authorized'} → throws StorageFailure and NO child-auth invoke fires.

  • Step 3: Run the SDK suite — green + commit

Run: cd packages/client_sdk && fvm flutter test Expected: PASS.

cd packages/client_sdk && fvm flutter analyze
graphify update .
git add infra/supabase/migrations/20260712000400_decline_child_rpc.sql packages/client_sdk/test/services/child_signup_service_test.dart packages/client_sdk/test/cloud/link_child_routing_test.dart
git commit -m "feat(schema,sdk): decline_child RPC (delete member row, return auth_user_id for child-auth delete) + decline coverage"

Task L1-T7b: child-auth service-role Edge Function (create pre-confirmed / delete) + SDK invoke wiring

Files:

  • Create: infra/supabase/functions/child-auth/index.ts
  • Modify: packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart (PostgrestPort: add invokeFunctionResult)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart + mapping_port.dart (implement/forward invokeFunctionResult)
  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (StoragePort: add provisionChildAuthRemote, deleteChildAuthRemote)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (implement both via db.invokeFunctionResult('child-auth', ...))
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart + local/local_storage_adapter.dart (cloud-only stubs) + cached/cached_storage_adapter.dart (forwarders)
  • Modify: packages/client_sdk/lib/src/services/child_signup_service.dart (provisionChildAuth, deleteChildAuth)
  • Modify: packages/client_sdk/lib/src/client/client.dart + client_impl.dart (facade provisionChildAuth / deleteChildAuth)
  • Modify: packages/client_sdk/test/support/fake_port.dart + test/cloud/fake_postgrest.dart (stubs + programmable invokeFunctionResult)
  • Create: packages/client_sdk/test/cloud/child_auth_routing_test.dart

Interfaces:

  • Consumes: the injected SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY (Edge runtime only), household_members.invite_token_hash / households.child_join_code_hash (L1-T2, for the create anti-abuse gate), the db.invokeFunction seam pattern (mirrors send-invite).

  • Produces (later tasks rely on these EXACT signatures):

    • PostgrestPort.invokeFunctionResult(String fn, Map<String,dynamic> body) → Future<Map<String,dynamic>> — a result-returning Edge-Function invoke (sibling to the fire-and-forget invokeFunction).
    • StoragePort.provisionChildAuthRemote({required String code, required String username, required String password}) → Future<({String authUserId, String email})> — cloud-only (child-auth create); throws ChildLinkException on a typed reason. Local adapters throw UnimplementedError.
    • StoragePort.deleteChildAuthRemote({required String authUserId, String? householdId}) → Future<void> — cloud-only (child-auth delete). Local adapters throw.
    • ChildSignupService.provisionChildAuth({required String code, required String username, required String password}) → Future<({String authUserId, String email})> (cloud-only) and ChildSignupService.deleteChildAuth(String authUserId) → Future<void> (cloud-only rollback).
    • Client.provisionChildAuth(...) + Client.deleteChildAuth(String authUserId) facade methods.
  • The create call is pre-auth (the child is not signed in yet) → the SDK's Supabase client has no session, so db.invokeFunctionResult sends the anon publishable key only (no user JWT). The delete call carries the current session's JWT (the parental member on decline, or the child on rollback), which verify_jwt accepts.

  • Step 1: Write the Edge Function

Create infra/supabase/functions/child-auth/index.ts (mirrors the send-invite house style — Deno.serve, CORS, createClient with the injected service-role key; JSON in/out):

// child-auth — service-role provisioning of a CHILD's Supabase auth user for the
// consent-gated self-signup (spec decision 3). A child has NO real email and the
// project's email-confirm is ON, so the client-side signUp+confirm path cannot be
// used. This function owns the child auth lifecycle:
// * create: mint a PRE-CONFIRMED auth user under a SYNTHETIC, non-deliverable
// email derived from the username (never shown to anyone; not real PII), so
// there is no email-confirm wall. Gated on a valid child code (anti-abuse);
// the authoritative code binding still happens in the link_child RPC, run as
// the child's JWT after this returns.
// * delete: remove a child auth user on decline (parental) or rollback (self).
// Service-role only (SUPABASE_SERVICE_ROLE_KEY injected by the runtime); the
// service-role key never ships in the app. verify_jwt stays ON — the anon
// publishable key satisfies it for the pre-auth create call; the parental (or
// self) JWT authorizes delete.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { ...cors, "Content-Type": "application/json" },
});

// Synthetic, non-deliverable identity for a child — never shown; not real PII.
// Derived from the username ALONE so a returning child's username-based sign-in
// (#155) reproduces it without the household (which they don't re-type). The
// reserved `.invalid` TLD guarantees the address can never receive mail; global
// username uniqueness (GoTrue's unique-email invariant, surfaced below as
// `already_linked`) is the cross-household disambiguator.
const syntheticEmail = (username: string) =>
`child.${username.trim().toLowerCase()}@child.rewhaven.invalid`;

const sha256Hex = async (value: string) => {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};

Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405);
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
try {
const payload = await req.json();
const action = payload?.action;

// ── CREATE: mint a pre-confirmed child auth user ───────────────────────────
if (action === "create") {
const { code, username, password } = payload;
if (
typeof code !== "string" || typeof username !== "string" ||
typeof password !== "string" || !code || !username || !password
) {
return json({ ok: false, reason: "invalid_input" }, 400);
}
// Anti-abuse gate: the code must resolve to a real child slot before we
// mint an account (so garbage codes never create orphan auth users). This
// is a pre-mint existence check ONLY — the authoritative code validation
// (expiry/already-linked/not-a-child) still runs in link_child as the child.
const hash = await sha256Hex(code);
const { data: attachRow } = await admin
.from("household_members")
.select("id")
.eq("invite_token_hash", hash)
.is("auth_user_id", null)
.eq("kind", "child")
.maybeSingle();
let gated = attachRow != null;
if (!gated) {
const { data: hh } = await admin
.from("households")
.select("id")
.eq("child_join_code_hash", hash)
.maybeSingle();
gated = hh != null;
}
if (!gated) return json({ ok: false, reason: "invalid_code" });

const email = syntheticEmail(username);
const { data, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { child: true, username: username.trim() },
});
if (error) {
// A duplicate synthetic email = this child username is already taken.
const taken = /already|registered|exists|duplicate/i.test(error.message);
return json(
{ ok: false, reason: taken ? "already_linked" : "auth_error" },
taken ? 200 : 500,
);
}
return json({ ok: true, user_id: data.user.id, email });
}

// ── DELETE: remove a child auth user (parental decline / self rollback) ────
if (action === "delete") {
const authUserId = payload?.auth_user_id;
const householdId = payload?.household_id;
if (typeof authUserId !== "string" || !authUserId) {
return json({ ok: false, reason: "invalid_input" }, 400);
}
// Authorize the caller (verify_jwt guarantees a token is present).
const token =
req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "") ?? "";
const { data: caller } = await admin.auth.getUser(token);
const callerUid = caller?.user?.id;
if (!callerUid) return json({ ok: false, reason: "not_authorized" }, 401);
const isSelf = callerUid === authUserId; // child rolling back its own signup
if (!isSelf) {
// A parental (parent/co_parent) member of the child's household may
// delete on decline; household_id is returned by decline_child.
if (typeof householdId !== "string" || !householdId) {
return json({ ok: false, reason: "not_authorized" }, 403);
}
const { data: parentRow } = await admin
.from("household_members")
.select("id")
.eq("household_id", householdId)
.eq("auth_user_id", callerUid)
.in("kind", ["parent", "co_parent"])
.eq("status", "active")
.maybeSingle();
if (parentRow == null) {
return json({ ok: false, reason: "not_authorized" }, 403);
}
}
// Safety: never delete an auth user still backing an active/consented
// member (only a memberless orphan or a still-frozen pendingConsent child).
const { data: member } = await admin
.from("household_members")
.select("status, consent_state")
.eq("auth_user_id", authUserId)
.maybeSingle();
if (
member != null &&
(member.status !== "pendingConsent" || member.consent_state === "granted")
) {
return json({ ok: false, reason: "not_deletable" });
}
const { error } = await admin.auth.admin.deleteUser(authUserId);
if (error) return json({ ok: false, reason: error.message }, 500);
return json({ ok: true });
}

return json({ ok: false, reason: "unknown_action" }, 400);
} catch (e) {
return json({ error: String(e) }, 500);
}
});
  • Step 2: Add the result-returning invoke seam

In packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart, add to abstract class PostgrestPort (after invokeFunction):

/// Invokes a deployed Edge Function [fn] and RETURNS its decoded JSON object
/// (unlike [invokeFunction], which fire-and-forgets). The child-auth
/// create/delete calls need the returned `{ok, ...}` body.
Future<Map<String, dynamic>> invokeFunctionResult(
String fn,
Map<String, dynamic> body,
);

In supabase_storage_adapter.dart (mirror the existing invokeFunction; the FunctionsClient attaches the current session's JWT + anon apikey automatically — none for the pre-auth create):

@override
Future<Map<String, dynamic>> invokeFunctionResult(
String fn,
Map<String, dynamic> body,
) async {
final res = await _c.functions.invoke(fn, body: body);
return (res.data as Map).cast<String, dynamic>();
}

In mapping_port.dart (forward through the failure-mapping wrapper, like rpc):

@override
Future<Map<String, dynamic>> invokeFunctionResult(
String fn,
Map<String, dynamic> body,
) =>
_w(() => _inner.invokeFunctionResult(fn, body));

In test/cloud/fake_postgrest.dart, add ONLY a programmable per-fn result map and REUSE the existing lastInvokeFn / lastInvokeBody / throwOnInvoke fields (already present for invokeFunction) so the routing tests can assert the recorded body:

/// Canned per-function responses for invokeFunctionResult (child-auth tests).
final Map<String, Map<String, dynamic>> invokeResults = {};

@override
Future<Map<String, dynamic>> invokeFunctionResult(
String fn,
Map<String, dynamic> body,
) async {
lastInvokeFn = fn;
lastInvokeBody = Map.of(body);
if (throwOnInvoke != null) throw throwOnInvoke!;
return invokeResults[fn] ?? const {'ok': true};
}
  • Step 3: Extend StoragePort + cloud impl + stubs

In adapter.dart, add to abstract class StoragePort (Members section, after declineChildRemote):

/// Cloud-only: provision the child's PRE-CONFIRMED Supabase auth user via the
/// service-role `child-auth` Edge Function (`create`) — a SYNTHETIC,
/// non-deliverable email (spec decision 3). Pre-auth: invoked with the anon key
/// only (the child is not signed in yet). Returns the new auth user id + the
/// synthetic email the app must immediately sign in with. Throws a
/// [ChildLinkException] on a typed reason (invalidCode / alreadyLinked). Local
/// adapters do NOT implement this — child self-signup is a cloud concern.
Future<({String authUserId, String email})> provisionChildAuthRemote({
required String code,
required String username,
required String password,
});

/// Cloud-only: delete a child's Supabase auth user via `child-auth` (`delete`).
/// [householdId] (returned by `decline_child`) lets the function authorize a
/// parental deleter; omitted for a self-rollback (caller == target). Local
/// adapters throw.
Future<void> deleteChildAuthRemote({
required String authUserId,
String? householdId,
});

In supabase_households.dart (inside mixin Households, alongside linkChildRemote):

Future<({String authUserId, String email})> provisionChildAuthRemote({
required String code,
required String username,
required String password,
}) async {
final result = await db.invokeFunctionResult('child-auth', {
'action': 'create',
'code': code,
'username': username,
'password': password,
});
if (result['ok'] == true) {
return (
authUserId: result['user_id'] as String,
email: result['email'] as String,
);
}
throw _childLinkRejection(result['reason'] as String?);
}

Future<void> deleteChildAuthRemote({
required String authUserId,
String? householdId,
}) async {
final result = await db.invokeFunctionResult('child-auth', {
'action': 'delete',
'auth_user_id': authUserId,
if (householdId != null) 'household_id': householdId,
});
if (result['ok'] != true) {
throw StorageFailure(
'Could not delete the child sign-in account (${result['reason']}).');
}
}

In in_memory_storage_adapter.dart + local_storage_adapter.dart, add cloud-only stubs (mirror the linkChildRemote stubs):

@override
Future<({String authUserId, String email})> provisionChildAuthRemote({
required String code,
required String username,
required String password,
}) =>
throw UnimplementedError('provisionChildAuthRemote is cloud-only');

@override
Future<void> deleteChildAuthRemote({
required String authUserId,
String? householdId,
}) =>
throw UnimplementedError('deleteChildAuthRemote is cloud-only');

In cached_storage_adapter.dart, forward both to _durable. In test/support/fake_port.dart, add matching UnimplementedError stubs (the in-memory parity path never calls them — useRemoteChildLink is false in unit tests).

  • Step 4: Service methods

In child_signup_service.dart, add (cloud-only — local/in-memory self-signup takes authUserId directly in signUpChild):

/// Provision the child's PRE-CONFIRMED Supabase auth user (synthetic email) via
/// the `child-auth` Edge Function, BEFORE the child is authenticated. The app
/// then signs in with the returned synthetic email + the child's password and
/// calls [signUpChild] (link_child) as the now-authenticated child. Cloud-only.
Future<({String authUserId, String email})> provisionChildAuth({
required String code,
required String username,
required String password,
}) {
if (!_useRemoteChildLink) {
throw const StorageFailure(
'Child auth provisioning is a cloud-only operation.');
}
return _storage.provisionChildAuthRemote(
code: code, username: username, password: password);
}

/// Roll back a just-minted child auth user when the follow-up link_child fails,
/// so a rejected code leaves no orphan auth account (COPPA). Self-authorized
/// (the child is signed in as the target). Cloud-only.
Future<void> deleteChildAuth(String authUserId) {
if (!_useRemoteChildLink) return Future<void>.value();
return _storage.deleteChildAuthRemote(authUserId: authUserId);
}
  • Step 5: Facade + barrel

Add to client.dart (abstract Client):

Future<({String authUserId, String email})> provisionChildAuth({
required String code,
required String username,
required String password,
});
Future<void> deleteChildAuth(String authUserId);

Delegate both from client_impl.dart to _childSignupService. No new barrel export is needed (the record return type needs none; ChildLinkException is already exported by L1-T4).

  • Step 6: Routing test — the create/delete contract

Create packages/client_sdk/test/cloud/child_auth_routing_test.dart (mirror link_child_routing_test.dart): a FakePostgrest with programmable invokeResults['child-auth'], a SupabaseStorageAdapter.forTest(fake), and a ChildSignupService(storage: adapter, useRemoteChildLink: true). Assert:

  • invokeResults['child-auth'] = {'ok': true, 'user_id': 'auth-kid', 'email': '[email protected]'}provisionChildAuth(code:'c', username:'robin', password:'pw') returns (authUserId: 'auth-kid', email: '[email protected]'), and fake.lastInvokeBody == {action:'create', code:'c', username:'robin', password:'pw'}.

  • invokeResults['child-auth'] = {'ok': false, 'reason': 'already_linked'}provisionChildAuth throws ChildLinkException with reason == ChildLinkRejectionReason.alreadyLinked.

  • invokeResults['child-auth'] = {'ok': true}deleteChildAuth('auth-kid') completes and fake.lastInvokeBody == {action:'delete', auth_user_id:'auth-kid'}.

  • Step 7: Controller deploys the function (deploy is a controller step, not a unit test)

The controller (not this task) deploys child-auth via the Supabase MCP deploy_edge_function (project bgedvvmihygwxhjxlvfu) and smokes it out of band:

  • create with a bogus code → {ok:false, reason:'invalid_code'}, and NO auth user minted.

  • create with a real (seeded) attach code + a fresh username → {ok:true, user_id, email:'child.<username>@child.rewhaven.invalid'}; confirm the auth.users row exists and is email_confirmed.

  • delete (as the just-minted child's JWT, self) → {ok:true}; the auth.users row is gone.

  • delete a random uuid as a non-parental caller → not_authorized. Record results in .superpowers/sdd/progress.md (house style). The deploy + smoke is a controller step — the offline SDK suite proves only the routing contract (Step 6).

  • Step 8: Run the SDK suite — green + commit

Run: cd packages/client_sdk && fvm flutter test Expected: PASS (SDK ≥ 1049 + the new routing tests).

cd packages/client_sdk && fvm flutter analyze
graphify update .
git add infra/supabase/functions/child-auth/index.ts packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart packages/client_sdk/lib/src/adapters/adapter.dart packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart packages/client_sdk/lib/src/services/child_signup_service.dart packages/client_sdk/lib/src/client/client.dart packages/client_sdk/lib/src/client/client_impl.dart packages/client_sdk/test/support/fake_port.dart packages/client_sdk/test/cloud/fake_postgrest.dart packages/client_sdk/test/cloud/child_auth_routing_test.dart
git commit -m "feat(sdk,infra): child-auth Edge Function (synthetic pre-confirmed child auth) + SDK invoke wiring"

Task L1-T8: App — child signup screen (code + username/password + display name)

Files:

  • Create: app/lib/inside/routes/unauthenticated/child_signup/child_signup_page.dart
  • Create: app/lib/inside/routes/unauthenticated/child_signup/bloc.dart + state.dart + events.dart
  • Modify: app/lib/inside/routes/router.dart (+ regenerate router.gr.dart) — add ChildSignupRoute (unauthenticated entry)
  • Modify: app/lib/outside/repositories/household/household_repository.dart (signUpChild delegate)
  • Modify: app/lib/inside/i18n/strings... (child-signup copy + typed code errors)
  • Modify: an existing unauthenticated entry (sign-in or setup) to link to the child signup screen (VERIFY the entry surface)
  • Test: app/test/flows/child_signup_test.dart

Interfaces:

  • Consumes: HouseholdRepository.provisionChildAuth({code, username, password})Client.provisionChildAuth(...) (L1-T7b — mints the child's PRE-CONFIRMED synthetic-email auth user and returns (authUserId, email)); the existing AuthRepository.signInWithPassword({email, password}) seam (ClientAuth.signInWithPassword — the child signs in with the RETURNED synthetic email, NOT AuthRepository.signUp; the email-confirm wall is already bypassed by email_confirm: true in the Edge Function); HouseholdRepository.signUpChild({code, displayName, authUserId})Client.signUpChild(...) (L1-T4); HouseholdRepository.deleteChildAuth(authUserId) (L1-T7b, rollback); ChildLinkException + ChildLinkRejectionReason (L1-T4). The child NEVER supplies a real email — the synthetic email is an internal identity minted + owned by the child-auth Edge Function; the child UX is username + password only.

  • Produces: a signed-out person enters a code + username + password + display name; the app (1) provisions the child's pre-confirmed auth user via child-auth (synthetic email), (2) signs in with the returned synthetic email + password, (3) calls signUpChild (link_child) as the now-authenticated child, landing them in pendingConsent. If link_child rejects, the app rolls back the just-minted auth user via deleteChildAuth (no orphan).

  • Step 1: Write the failing flow test

Create app/test/flows/child_signup_test.dart following app/test/flows/setup_test.dart shape (flowTest<MocksContainer> + createFlowConfig() + testAppBuilder(mocks)). Stub mocks.householdRepository.provisionChildAuth(code: any, username: any, password: any) to return (authUserId: 'auth-kid', email: '[email protected]'), mocks.authRepository.signInWithPassword(email: any, password: any) to succeed, and mocks.householdRepository.signUpChild(code: any, displayName: any, authUserId: any) to return a seeded pendingConsent child. Drive: navigate to the child signup screen, enter code KID-CODE, username robin, password (+ confirm), display name Robin, tap Submit; assert the app shows the waiting screen (or a success indicator). Add a rejection case: provisionChildAuth throws (or signUpChild throws) ChildLinkException(reason: invalidCode) → the typed error copy shows and (for the signUpChild-throws case) deleteChildAuth is invoked (rollback). Skeleton mirrors the invite flow tests; the load-bearing assertion is that provisionChildAuth + signUpChild are invoked with the entered code and the child is routed to WaitingRoute (L1-T9). VERIFY widget keys via graphify query "SignUpPage DsTextField keys username password submit" and reuse the same key convention (ChildSignup.code/username/password/confirm/displayName/submit).

  • Step 2: Run it — fails

Run: cd app && fvm flutter test test/flows/child_signup_test.dart Expected: FAIL — no ChildSignupRoute/page exists.

  • Step 3: Add the repository delegate

In household_repository.dart:

Future<({String authUserId, String email})> provisionChildAuth({
required String code,
required String username,
required String password,
}) =>
_clientProvider.client.provisionChildAuth(
code: code, username: username, password: password);

Future<HouseholdMember> signUpChild({
required String code,
required String displayName,
required String authUserId,
}) =>
_clientProvider.client.signUpChild(
code: code, displayName: displayName, authUserId: authUserId);

Future<void> deleteChildAuth(String authUserId) =>
_clientProvider.client.deleteChildAuth(authUserId);
  • Step 4: Build the child-signup bloc + state + events

Create child_signup/state.dart (equatable): fields code, username, password, confirmPassword, displayName, ChildSignupStatus status (idle/submitting/success/failure), String? errorMessage, bool passwordsMismatch, copyWith with setter-closures for nullable fields (mirror sign_up/state.dart). events.dart: field-changed events + ChildSignupSubmitted. bloc.dart (mirror sign_up/bloc.dart, but on submit, orchestrate the provision → sign-in → link handshake — the child never uses authRepository.signUp; the child-auth Edge Function owns their pre-confirmed synthetic-email identity):

Future<void> _onSubmitted(ChildSignupSubmitted e, Emitter emit) async {
if (state.password != state.confirmPassword) {
emit(state.copyWith(passwordsMismatch: true));
return;
}
emit(state.copyWith(status: ChildSignupStatus.submitting));
// 1. Mint the child's PRE-CONFIRMED auth user (synthetic email). Pre-auth.
final ({String authUserId, String email}) provision;
try {
provision = await _householdRepository.provisionChildAuth(
code: state.code,
username: state.username,
password: state.password,
);
} on ChildLinkException catch (ex) {
emit(state.copyWith(
status: ChildSignupStatus.failure,
setErrorMessage: () => _childLinkErrorCopy(ex.reason)));
return;
}
// 2. Sign in as the child with the RETURNED synthetic email + password.
await _authRepository.signInWithPassword(
email: provision.email, password: state.password);
// 3. Attach/create the pendingConsent member as the now-authenticated child.
try {
await _householdRepository.signUpChild(
code: state.code,
displayName: state.displayName,
authUserId: provision.authUserId,
);
} on ChildLinkException catch (ex) {
// Roll back the just-minted auth user so a rejected code leaves no orphan.
await _authRepository.signOut();
await _householdRepository.deleteChildAuth(provision.authUserId);
emit(state.copyWith(
status: ChildSignupStatus.failure,
setErrorMessage: () => _childLinkErrorCopy(ex.reason)));
return;
}
emit(state.copyWith(status: ChildSignupStatus.success));
}

Add a _childLinkErrorCopy(ChildLinkRejectionReason) switch → Strings.childCodeInvalid/Expired/AlreadyUsed/NotAChild.

String _childLinkErrorCopy(ChildLinkRejectionReason reason) => switch (reason) {
ChildLinkRejectionReason.expired => Strings.childCodeExpired,
ChildLinkRejectionReason.alreadyLinked => Strings.childCodeAlreadyUsed,
ChildLinkRejectionReason.notAChild => Strings.childCodeNotAChild,
ChildLinkRejectionReason.invalidCode => Strings.childCodeInvalid,
};
  • Step 5: Build the page + route

Create child_signup_page.dart (@RoutePage(), wrappedRoute() provides the bloc): a DsTextField for code, username, password, confirm, display name, and a Submit DsButton; inline password-mismatch + errorMessage copy. Add AutoRoute(page: ChildSignupRoute.page) to the UNAUTHENTICATED route set in router.dart (a signed-out child must reach it; VERIFY the unauthenticated route list + guard with graphify query "AppRouter unauthenticated routes SignInRoute UnauthenticatedGuard"). Regenerate: cd app && fvm dart run build_runner build --delete-conflicting-outputs. Link to it from the sign-in surface ("A child? Sign up with a code").

  • Step 6: Run the flow test + full app suite

Run: cd app && fvm flutter test test/flows/child_signup_test.dart Expected: PASS. Run: cd app && fvm flutter test Expected: PASS, app total ≥ 553 + the new flow test.

  • Step 7: Update graphify + commit
cd app && fvm flutter analyze
graphify update .
git add app/lib/inside/routes/unauthenticated/child_signup/child_signup_page.dart app/lib/inside/routes/unauthenticated/child_signup/bloc.dart app/lib/inside/routes/unauthenticated/child_signup/state.dart app/lib/inside/routes/unauthenticated/child_signup/events.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/lib/outside/repositories/household/household_repository.dart app/test/flows/child_signup_test.dart
git commit -m "feat(app): child signup screen (code + username/password + display name)"

(Also git add the i18n strings file + any regenerated .g.dart. Never stage graphify-out/.)


Task L1-T9: App — waiting screen (a frozen child is routed here and ONLY here)

Files:

  • Create: app/lib/inside/routes/authenticated/waiting/waiting_page.dart (+ a small poll cubit if needed)
  • Modify: app/lib/inside/routes/router.dart (+ router.gr.dart) — add WaitingRoute; the authenticated guard routes a frozen child to it
  • Modify: the authenticated guard/shell that resolves the landing route (VERIFY: graphify query "authenticated guard shell resolve landing route setup home member")
  • Test: app/test/flows/waiting_screen_test.dart

Interfaces:

  • Consumes: the resolved current member (kind + consentState + status). A child is "frozen" when kind == MemberKind.child && consentState != ConsentState.granted (covers pendingConsent and a later revoked re-freeze). The RLS self-row read guarantees the child can still read its own member row to poll.

  • Produces: a pendingConsent (or revoked) child sees ONLY the waiting screen — no tabs, no household data, no editable profile — and polls status; on activation (status active + consentState granted) the guard routes them into normal supervised child mode.

  • Step 1: Write the failing routing flow test

Create app/test/flows/waiting_screen_test.dart: seed the authenticated session as a pendingConsent child (VERIFY the flow harness's auth+member seeding via graphify query "flow harness seed authenticated member currentMember MocksContainer"), warp to home, and assert the WaitingPage is shown and that NO household tab/roster widget is present. Add a second case: a granted/active child does NOT see the waiting screen (routes to the normal child home). The load-bearing assertion: a frozen child's tree contains the waiting marker and none of the household surfaces.

  • Step 2: Run it — fails

Run: cd app && fvm flutter test test/flows/waiting_screen_test.dart Expected: FAIL — no WaitingRoute/guard branch exists.

  • Step 3: Build the waiting page

Create waiting_page.dart (@RoutePage()): a calm "Waiting for a grown-up to approve your account" screen (DS atoms; a11y floor), a Refresh action that re-resolves the session, and a Sign-out action. No navigation into household surfaces. Add AutoRoute(page: WaitingRoute.page) under the authenticated shell in router.dart; regenerate codegen.

  • Step 4: Route the frozen child to it (and ONLY it)

In the authenticated guard/landing resolver, add — BEFORE the normal member landing — a branch: if the resolved current member is a child with consentState != granted, redirect to WaitingRoute (and prevent navigation elsewhere). VERIFY the exact guard (graphify query "AppRouter AuthenticatedGuard onNavigation resolve member landing") and place the frozen-child branch alongside the existing terms/consent gates (terms_session_gate.dart / consent_renewal_nudge.dart are the sibling session gates). Ensure the waiting screen re-resolves on Refresh so activation drops the child through.

  • Step 5: Run the flow test + app suite

Run: cd app && fvm flutter test test/flows/waiting_screen_test.dart Expected: PASS. Run: cd app && fvm flutter test Expected: PASS, app total ≥ 553 + new tests.

  • Step 6: Update graphify + commit
cd app && fvm flutter analyze
graphify update .
git add app/lib/inside/routes/authenticated/waiting/waiting_page.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/test/flows/waiting_screen_test.dart
git commit -m "feat(app): waiting screen — a frozen child is routed here and only here"

(Also git add the guard/shell file(s) you modified. Never stage graphify-out/.)


Task L1-T10: App — household approval surface (VPC agreement → captureConsent / decline; new-child age + 13+ conversion)

Files:

  • Create: app/lib/inside/routes/authenticated/members/child_approvals_section.dart (the "‹name› is waiting" list) + child_approval_sheet.dart (VPC agreement)
  • Modify: app/lib/inside/blocs/household/members_bloc.dart + members_state.dart (pending-children list + ChildApproved/ChildDeclined events)
  • Modify: app/lib/outside/repositories/household/household_repository.dart (pendingConsentChildren / declineChild delegates; captureConsent already exists — VERIFY)
  • Modify: app/lib/inside/i18n/strings... (approval + VPC agreement + age copy)
  • Test: app/test/flows/child_approval_test.dart

Interfaces:

  • Consumes: Client.pendingConsentChildren() + Client.declineChild(...) (L1-T4/T7), the existing consent capture path (Client.captureConsent({actingMemberId, memberId, method, tosVersion, privacyVersion}) — VERIFY the repository delegate name), ConsentMethod (email_plus / card), the acting member id (the authenticated parental member).

  • Produces: any parental/admin member sees "‹name› is waiting to join" → opens the VPC agreement → on Agree, captureConsent (activation flips the child to active/granted); or Decline (declineChild deletes the pending member + auth account). New-child path: the surface collects/confirms the child's age; 13+ converts to the adult member path (kind → an adult kind, no VPC) instead of capturing child consent.

  • Step 1: Write the failing approval flow test

Create app/test/flows/child_approval_test.dart: stub mocks.householdRepository.pendingConsentChildren() to return one pendingConsent child; warp to the members/approvals surface; assert "‹name› is waiting" appears. Then tap it, Agree to the VPC agreement, and assert mocks.householdRepository.captureConsent(...) (or the consent delegate) is invoked with the child's id + a ConsentMethod. Add a Decline case asserting declineChild is invoked. Add a 13+ case: a new-child pending item with age set to 13 at approval invokes the adult-conversion path (member update to an adult kind), NOT captureConsent. VERIFY the members surface + keys via graphify query "members roster approvals sheet MembersBloc events keys".

  • Step 2: Run it — fails

Run: cd app && fvm flutter test test/flows/child_approval_test.dart Expected: FAIL — no approval section/events exist.

  • Step 3: Repository delegates

In household_repository.dart:

Future<List<HouseholdMember>> pendingConsentChildren() =>
_clientProvider.client.pendingConsentChildren();

Future<void> declineChild({
required String actingMemberId,
required String memberId,
}) =>
_clientProvider.client.declineChild(
actingMemberId: actingMemberId, memberId: memberId);

(captureConsent delegate already exists on the consent/governance repository — VERIFY with graphify query "captureConsent repository delegate ConsentMethod"; reuse it. The 13+ conversion reuses the existing member-update delegate — VERIFY updateMember/saveMember on the members path.)

  • Step 4: Bloc — pending list + approve/decline events

In members_bloc.dart, add a load of pendingConsentChildren() into members_state.dart (a List<HouseholdMember> pendingChildren field, default const [], in copyWith + props), and handlers:

  • ChildApproved(memberId, method) → resolve the acting member id, call the consent capture delegate with kCurrentTosVersion/kCurrentPrivacyVersion, then reload the roster + pending list.

  • ChildDeclined(memberId)declineChild(actingMemberId, memberId), then reload.

  • For the new-child path where the parent sets age ≥ 13 → call the member-update delegate to convert kind to the adult kind (VERIFY the adult kind: MemberKind.otherAdult per 20260628030000_member_kind_other_adult; graphify query "MemberKind otherAdult co_parent values") + set status: active, and DO NOT capture child consent.

  • Step 5: Build the approval section + VPC agreement sheet

Create child_approvals_section.dart — a list of pending children rendered on the members/approvals surface (reuse the members roster patterns). Each item → showChildApprovalSheet(context, member, bloc) (child_approval_sheet.dart): shows the VPC agreement copy, a ConsentMethod selection (free = email_plus, paid = card — the mechanism only; sufficiency is the legal gate), an age field/confirm (new-child path), an Agree & Approve button (bloc.add(ChildApproved(...)) or the 13+ conversion) and a Decline button (bloc.add(ChildDeclined(...))). Wire the section into the existing members/approvals surface (VERIFY the host widget).

  • Step 6: Run the flow test + app suite

Run: cd app && fvm flutter test test/flows/child_approval_test.dart Expected: PASS. Run: cd app && fvm flutter test Expected: PASS, app total ≥ 553 + new tests.

  • Step 7: Update graphify + commit
cd app && fvm flutter analyze
graphify update .
git add app/lib/inside/routes/authenticated/members/child_approvals_section.dart app/lib/inside/routes/authenticated/members/child_approval_sheet.dart app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/blocs/household/members_state.dart app/lib/outside/repositories/household/household_repository.dart app/test/flows/child_approval_test.dart
git commit -m "feat(app): household approval surface — VPC agree -> captureConsent, decline, 13+ conversion"

(Also git add the i18n strings + any regenerated members_state.g.dart. Never stage graphify-out/.)


Task L1-T11: LOAD-BEARING SQL-smoke freeze/activation live verification + suites green + deploy

Files:

  • Create: packages/client_sdk/test/cloud/child_freeze_live_test.dart (authored self-skipping; the EXECUTED proof is the SQL smoke)
  • (deploy + verification checklist — the controller applies migrations + runs the SQL smoke)

Interfaces:

  • Consumes: all L1 tasks merged + the deployed migrations (20260712000100..000400) + the app build.

  • Produces: the load-bearing proof — a pendingConsent child is FROZEN at RLS (reads/writes nothing household-scoped, reads only its own status), then ACTIVE after captureConsent; plus decline→deletion, wrong-code, parental-only consent, and 13+ conversion — executed as a SQL DO-block with simulated JWTs (the headless flutter client cannot attach a token under the sb_publishable_ key).

  • Step 1: Author the self-skipping flutter live test (parity with the invite live test)

Create packages/client_sdk/test/cloud/child_freeze_live_test.dart modeled 1:1 on accept_invite_live_test.dart: @Tags(['live']), skip-gated on SUPABASE_URL (registers one skipped placeholder offline so the SDK baseline stays green), the same documented PRECONDITIONS block (token-attaching client + pre-provisioned email-confirmed identities), and — in the doc comment — the explicit note that the executed go-live proof is the SQL smoke below, not this HTTP test. Sketch the two-identity intent (child C in pendingConsent cannot read the household; can read only its own status; after A's captureConsent C reads the household) so the intent is source-visible.

  • Step 2: Confirm it self-skips offline

Run: cd packages/client_sdk && fvm flutter test test/cloud/child_freeze_live_test.dart Expected: PASS with 1 skipped. No live calls.

  • Step 3: Confirm offline suites are green

Run: cd packages/client_sdk && fvm flutter test → PASS (SDK ≥ 1049, live tests self-skipped). Run: cd app && fvm flutter test → PASS (app ≥ 553).

  • Step 4: Controller applies the four migrations live (in filename order)

The controller (not this task) applies, IN ORDER, against project bgedvvmihygwxhjxlvfu, running each file's LIVE SMOKE PROBES after apply: 20260712000100_member_status_pending_consent.sql20260712000200_link_child_rpc.sql20260712000300_child_consent_rls_freeze.sql20260712000400_decline_child_rpc.sql. The controller ALSO deploys the child-auth Edge Function (Supabase MCP deploy_edge_function) and runs its create/delete smoke (L1-T7b Step 7) — the child signup + decline flows depend on it.

  • Step 5: Run the LOAD-BEARING SQL smoke (DO-block, simulated JWTs)

The controller runs this DO-block against the live DB (house-style simulated auth via set_config('request.jwt.claims', ...); a helper set_local role authenticated + claims per identity). It is the executed proof of the freeze/activation contract:

-- CHILD FREEZE/ACTIVATION SQL SMOKE (spec §Testing, the load-bearing proof).
-- Simulates two auth identities via request.jwt.claims so auth.uid()/auth.email()
-- resolve per-identity under the real RLS policies + the live RPCs. Run in a
-- transaction and ROLLBACK at the end (no persisted fixtures).
begin;
-- Fixture (service-role / definer context): household H, parent A (auth AU),
-- shadow child CR with a per-child attach code hash for 'attach-smoke'.
-- ... insert households H; household_members A (parent, active, owner, AU);
-- household_members CR (kind=child, status=shadow, age=8,
-- invite_token_hash = encode(extensions.digest('attach-smoke','sha256'),'hex'));
-- ... create child auth identity CU in auth.users (email-confirmed).

-- (1) CHILD LINKS (as CU):
select set_config('request.jwt.claims',
json_build_object('sub', '<CU>', 'email', '[email protected]', 'role','authenticated')::text, true);
select set_config('role','authenticated', true);
select public.link_child('attach-smoke', ''); -- EXPECT {ok:true, path:'attach'}
-- CR is now auth_user_id=CU, status='pendingConsent', invite_token_hash null.

-- (2) FROZEN AT RLS (still as CU):
-- select member_household_ids() -> EXPECT does NOT contain H.
-- select count(*) from public.households where id = H -> EXPECT 0.
-- select count(*) from public.household_members where household_id = H -> EXPECT 1
-- (only CR's own row via household_members_self_read).
-- select status from public.household_members where auth_user_id = CU -> EXPECT 'pendingConsent'.
-- attempt: insert/update any household-scoped row -> EXPECT RLS denial (0 rows / 42501).

-- (3) PARENTAL-ONLY CONSENT then ACTIVATION (as AU):
select set_config('request.jwt.claims',
json_build_object('sub','<AU>','email','[email protected]','role','authenticated')::text, true);
-- Insert a consents row (state='granted', method='email_plus') for CR via the
-- owner-gated INSERT policy (the sync_member_consent_state trigger flips
-- consent_state='granted'), then PATCH household_members set status='active'
-- where id=CR -> EXPECT accepted (status is not a trigger-guarded column).
-- NEGATIVE: as CU, attempt the same consents insert -> EXPECT RLS denial
-- (captureConsent is parental-only).

-- (4) CHILD NOW ACTIVE (as CU):
-- select member_household_ids() -> EXPECT contains H (granted child admitted).
-- select count(*) from public.households where id = H -> EXPECT 1.

-- (5) DECLINE -> DELETION (fresh frozen child FC + auth FU; as AU):
select public.decline_child('<FC>');
-- EXPECT {ok:true, auth_user_id:FU, household_id:H}; then no household_members
-- FC row. (The auth.users FU row is deleted by the child-auth Edge Function,
-- NOT the RPC — verified out of band by the controller's child-auth `delete`
-- smoke in L1-T7b/Step 6 below, since a SQL DO-block cannot invoke a function.)
-- NEGATIVE: as FU (before decline), decline_child(FC) -> EXPECT not_authorized.

-- (6) WRONG CODE (as a fresh identity):
-- select public.link_child('no-such','') -> EXPECT {ok:false, reason:'invalid_code'}.
-- select public.link_child(<a co_parent invite raw token>,'') -> EXPECT not_a_child.

-- (7) 13+ CONVERSION (new-path child D at approval; as AU):
-- PATCH household_members set kind='other_adult', status='active' where id=D
-- (adult conversion, NO consents row) -> EXPECT accepted; assertChildDataAllowed
-- no longer applies (kind != child).
rollback;

Record the results in .superpowers/sdd/progress.md (house style) as the executed go-live proof.

  • Step 6: Deploy the app (cloud) + manual smoke

Deploy via the existing path (VERIFY scripts/deploy-app-cloud.sh). Manually: a parent issues a per-child attach code (or a household join code) and shares it; a signed-out child opens the child signup screen, enters the code + a username/password + display name, and lands on the waiting screen (no household data visible); the parent sees "‹name› is waiting", completes the VPC agreement, and the child drops into supervised child mode. Decline on a second pending child removes it.

  • Step 7: Commit the live test + tag the shippable Layer-1 milestone (no push — controller pushes)
git add packages/client_sdk/test/cloud/child_freeze_live_test.dart
git commit -m "test(sdk): self-skipping child-freeze live test; SQL-smoke is the executed proof"
git log --oneline -14

Confirm L1-T1..T11 commits are present. Layer 1 is complete and shippable.


LAYER 2 — Delivery + polish (builds on Layer 1)

Task L2-T1: Household "a child is waiting" notification

Files:

  • Modify: app/lib/inside/routes/authenticated/... (a badge/banner on the members/approvals entry when pendingConsentChildren() is non-empty)
  • Modify: the app-scope household bloc/stream that already loads the roster (VERIFY the composition root)
  • Test: app/test/flows/child_waiting_notification_test.dart

Interfaces:

  • Consumes: pendingConsentChildren() (L1-T4); the existing roster refresh path.

  • Produces: any parental member sees an in-app "a child is waiting to join" indicator (badge + count) that deep-links to the approval surface. Transport (push/email) stays out of scope (spec).

  • Step 1: Write a flow test: with one pending child, the members entry shows a count badge; with none, no badge. Run → FAIL.

  • Step 2: Derive the badge from the pending-children count already loaded in members_state (L1-T10); surface it on the members/approvals nav entry (VERIFY the nav host). Run → PASS.

  • Step 3: graphify update . + commit (explicit paths).


Files:

  • Create: infra/supabase/functions/expire-pending-children/index.ts
  • Modify: infra/supabase/migrations/2026071300xxxx_pending_child_expiry_index.sql (optional index on (status, created_at) where status='pendingConsent')

Interfaces:

  • Consumes: service-role env (function runtime only — never shipped in the app), the created_at of pendingConsent children, and the decline_child cleanup semantics (delete member + child auth.users).

  • Produces: a scheduled (pg_cron / Supabase scheduled function) sweep that deletes pendingConsent children older than 7 days and their auth.users accounts (COPPA delete-if-no-consent). Complements the lazy decline path.

  • Step 1: Author expire-pending-children/index.ts (service-role createClient): select household_members where status='pendingConsent' and created_at < now() - interval '7 days', and for each delete the member row + auth.admin.deleteUser(auth_user_id). Idempotent; logs a count. (Mirror the send-invite function's CORS/guard shape; it is invoked by the scheduler, never client-called.)

  • Step 2: Add the optional partial index migration file (LIVE SMOKE PROBES trailing block; controller applies).

  • Step 3: Controller deploys via the Supabase CLI (supabase functions deploy expire-pending-children) + schedules it (pg_cron select cron.schedule(...) or the dashboard scheduler); smoke: seed an 8-day-old pendingConsent child → run → EXPECT member + auth account gone; a 1-day-old one survives.

  • Step 4: Commit (explicit paths). No app-monorepo code required beyond the function + migration.


Task L2-T3: Tier-varying VPC method strength (email-plus vs card-on-file)

Files:

  • Modify: app/lib/inside/routes/authenticated/members/child_approval_sheet.dart (method selection driven by plan tier)
  • Modify: the plan/tier read (VERIFY graphify query "PlanKey plan tier feature flag entitlement")
  • Test: app/test/flows/child_approval_method_test.dart

Interfaces:

  • Consumes: the household's plan/tier (free vs paid) + ConsentMethod (email_plus / card). Engineering supports BOTH; which method clears the bar is the HARD legal gate (Global Constraints), NOT decided here.

  • Produces: the VPC agreement offers the tier-appropriate method — free → email-plus, paid → card-on-file — recorded in the ConsentRecord.method audit field.

  • Step 1: Flow test: a free-tier household's approval sheet defaults to/offers email_plus; a paid-tier household offers card. Run → FAIL.

  • Step 2: Read the tier and select the ConsentMethod passed to captureConsent. Keep the mechanism generic (do not hardcode legal sufficiency). Run → PASS.

  • Step 3: graphify update . + commit (explicit paths).

Legal-gate note: this task ships the MECHANISM only. Do NOT mark VPC "COPPA-compliant" — the sufficiency of email-plus/card is the HARD legal review in Global Constraints and blocks production.


Task L2-T4: Decline-UX niceties

Files:

  • Modify: app/lib/inside/routes/authenticated/members/child_approval_sheet.dart (confirmation dialog + undo-window copy)
  • Test: app/test/flows/child_decline_ux_test.dart

Interfaces:

  • Consumes: declineChild (L1-T7).

  • Produces: a decline confirmation ("This deletes ‹name›'s account and cannot be undone.") before the destructive declineChild, and a clear post-decline toast. No new SDK/schema surface.

  • Step 1: Flow test: tapping Decline shows a confirmation; only on confirm is declineChild invoked. Run → FAIL.

  • Step 2: Add the confirmation dialog + copy. Run → PASS.

  • Step 3: graphify update . + commit (explicit paths).


Self-Review (performed against the spec)

1. Spec coverage — every spec section maps to a task:

  • Decision 1 (parent-anchored code entry) → L1-T2 (RPC binds to the code), L1-T8 (child enters the code).
  • Decision 2 (both attach + new paths converge on pendingConsent) → L1-T2 (RPC both paths), L1-T4 (SDK parity), L1-T11 (SQL smoke both paths).
  • Decision 3 (username + password login, NOT the email-bound accept) → L1-T7b (the child-auth Edge Function mints a PRE-CONFIRMED, synthetic-email auth user so an email-less child bypasses the email-confirm wall) + L1-T8 (provision → sign-in-with-synthetic-email → link; explicitly not accept_invite, and NOT AuthRepository.signUp).
  • Decision 4 (MemberStatus.pendingConsent) → L1-T1.
  • Decision 5 (triple freeze) → L1-T3 (RLS), L1-T5 (assertChildDataAllowed coverage), L1-T9 (UI route lock); proven together in L1-T11.
  • Decision 6 (existing captureConsent, any parental/admin) → L1-T5 (activation flip), L1-T10 (approval surface, any parental).
  • Decision 7 (decline/expiry deletes member + auth) → L1-T7 (decline RPC + SDK), L2-T2 (7-day sweep).
  • Decision 8 (13+ → adult path) → L1-T10 (conversion), L1-T11 (SQL smoke §7).
  • Decision 9 (data-minimization) → L1-T2 (new-path insert stores only display_name), Global Constraints, L2-T2 (delete-if-no-consent).
  • Schema bullets (status, link RPC, RLS freeze, captureConsent unchanged parental-only, cleanup) → L1-T1/T2/T3/T5/T7.
  • SDK bullets (child-signup service, pendingConsent codec, assertChildDataAllowed extend, pending read, activation=captureConsent, decline verb, revoke re-freeze) → L1-T4/T1/T5/T6/T7 (revoke re-freeze needs no code change: revokeConsent sets consentState=revoked, which the tightened member_household_ids() + the consentState != granted UI routing already treat as frozen — verified by the freeze predicate).
  • App bullets (signup screen, waiting screen, approval surface) → L1-T8/T9/T10.
  • Testing bullet (load-bearing two-identity freeze/activation) → L1-T11 SQL smoke (+ self-skipping flutter twin).
  • Launch gate (COPPA VPC sufficiency) → Global Constraints + L2-T3 note (mechanism only; sufficiency is legal).

2. Gaps / divergences flagged for the controller:

  • Household join code storage — the NEW path needs a household-level code; this plan adds households.child_join_code_hash (L1-T2) + a Household.childJoinCodeHash model field + issueHouseholdChildJoinCode (L1-T4). The spec named the household join code without pinning storage; this is the minimal house-consistent (hash-stored, single active code) reconciliation. Controller: confirm the household-code column + issuance surface (where the parent generates/shares it — folded into the approval/members surface).
  • decline_child deleting auth.users — RESOLVED (L1-T7b). The auth.users deletion no longer happens inside the definer RPC (which sidesteps the migration-runner-owns-auth.users uncertainty entirely). decline_child deletes ONLY the member row and returns the child's auth_user_id + household_id; the SDK then deletes the auth.users row via the service-role child-auth Edge Function (delete action, parental-authorized). The expiry sweep (L2-T2) deletes the member row then the auth user with its own service-role client. Split-owner, dual-gate; pre-consent children hold no other PII.
  • Username→auth mapping (#155) — RESOLVED (L1-T7b). The child does NOT go through AuthRepository.signUp; the child-auth Edge Function mints their auth user under a SYNTHETIC, non-deliverable email child.<username>@child.rewhaven.invalid (reserved .invalid TLD; never shown; not real PII), PRE-CONFIRMED (email_confirm: true) so there is no email-confirm wall. The app signs in with the RETURNED synthetic email + password (existing signInWithPassword), then calls link_child. The synthetic email is username-derived so a returning child's username-based sign-in reproduces it; global username uniqueness (GoTrue's unique-email invariant, surfaced as already_linked) disambiguates across households. Deliberate deviation from the decision's +householdId example: the returning child re-types only their username (never the household), so the authoritative synthesis is username-only — flagged for the controller, with household-level uniqueness enforced by the code-gated flow + the unique-email check. Controller: confirm #155's sign-in username→email synthesis matches child.<username>@child.rewhaven.invalid for a returning child (the one remaining #155 coordination point).
  • MemberStatus.pendingConsent wire string is camelCase ('pendingConsent'), consistent with MemberStatus.wireName => name and the JSON @JsonValue. It is a valid CHECK literal; kept camelCase to avoid overriding wireName. If the controller prefers snake (pending_consent), it must override wireName/fromWireName AND the @JsonValue AND the CHECK together — flagged, not done.
  • revokeConsent re-freeze routing — relies on the child routing keying on consentState != granted (not on status). A revoked active child keeps status=active but is frozen by RLS (consent_state != granted) and routed to the waiting screen by the same predicate. Confirm the L1-T9 guard predicate is kind==child && consentState != granted (covers both pendingConsent and revoked).

3. Type/signature consistency — verified across tasks: MemberStatus.pendingConsent/wireName 'pendingConsent'; ChildLinkRejectionReason {invalidCode,expired,alreadyLinked,notAChild} + ChildLinkException; StoragePort.linkChildRemote({code,displayName})→Future<String> + declineChildRemote({memberId}); ChildSignupService.signUpChild({code,displayName,authUserId}) / issueChildAttachCode / issueHouseholdChildJoinCode / pendingConsentChildren / declineChild; Client facade mirrors; the RPC reason wire-strings (invalid_code/expired/already_linked/not_a_child) are used identically in the SQL (L1-T2), the cloud mapper (L1-T4), and the local parity path (L1-T4); captureConsent's unchanged signature drives activation via _projectMemberState (L1-T5); the RLS freeze predicate (kind='child' and consent_state is distinct from 'granted') matches the UI routing predicate (L1-T9) and the SDK gate (kind==child + consentState). Child-auth (L1-T7b) is consistent end-to-end: child-auth create returns {ok, user_id, email} and delete returns {ok} / {ok:false, reason}; the SDK maps create reasons through the SAME _childLinkRejection switch used by link_child (so invalid_code/already_linkedChildLinkException with the matching ChildLinkRejectionReason); PostgrestPort.invokeFunctionResult(fn, body)→Future<Map<String,dynamic>>, StoragePort.provisionChildAuthRemote({code,username,password})→Future<({String authUserId, String email})> + deleteChildAuthRemote({authUserId, householdId?}), ChildSignupService.provisionChildAuth/deleteChildAuth, and the Client facade mirror those signatures; decline_child now returns {ok, auth_user_id, household_id} and declineChildRemote threads auth_user_id+household_id into the child-auth delete body — identical keys on both sides.