Skip to main content

Implementation Plan — Add an Adult to the Household

REQUIRED SUB-SKILL: superpowers:test-driven-development (red → green → refactor on every task).

Date: 2026-07-20 · Branch: feat/mvp1-personas-authz Source of truth: docs/superpowers/specs/2026-07-20-add-an-adult-to-household-design.md (incl. "OWNER DECISIONS — LOCKED 2026-07-20").


Goal

Ship "add an adult to the household" as ONE capability with two entry paths, per the LOCKED owner decisions:

  • PATH B (email invite — finish; smaller, lower-risk, do first): fix the "invite sent" UI feedback gap (the co-parent invite save currently emits MembersStatus.ready with no snackbar / no in-flight affordance). The G5 "join a household with a code" accept surface for an authenticated adult with no household already exists, is wired, and is tested (see Deviation D-1) — Path B therefore reduces to the feedback fix plus a coverage assertion.
  • PATH A (admin direct-create — new, security-sensitive): an admin-create-adult service-role Edge Function that authorizes the CALLER as an active parental admin/owner of the target household, enforces the isAdult firewall, mints an auth.users account + an active/{member} household_members row with a generated one-time temp password, sets a must_change_password flag in user metadata, logs an invite_events created row, and returns the temp password ONCE. Plus the SDK service→facade→adapter passthrough, the admin UI (temp password shown once), and a first-login must-change-password gate that blocks app access until the adult rotates the temp password.

Both paths converge on: an adult member row status='active', roles={member}, linked to an auth.users account, promotable later by an admin (governance decision 2, enforced by guard_member_privilege_columns).

Architecture

ONE data path (never bypassed): Bloc/Cubit → Repository → Client facade → Service → Adapter. Presentation never imports supabase/drift. Domain rules live in the SDK Service; repositories are thin passthroughs. Privileged writes that need the service-role key live ONLY in Edge Functions. Every privileged step has a DUAL GATE: service authz AND RLS. Guard RPCs follow the house pattern (SECURITY DEFINER, pinned search_path='public', revoke public/anon + grant authenticated, typed jsonb {ok:false, reason:…} never bare raise).

Path A mirrors the PROVEN child-auth Edge Function + its SDK wiring (provisionChildAuthRemote/deleteChildAuthRemotedb.invokeFunctionResult/invokeFunction), minus the COPPA consent gate and the child synthetic-email machinery.

Tech Stack

  • Dart/Flutter monorepo (single pub workspace, one lockfile). Flutter 3.44 / Dart 3.9.
  • bloc/flutter_bloc, equatable, json_serializable (codegen in CI; no hand-maintained .g.dart).
  • SDK is pure Dart (no dart:ui); cloud adapter wraps SupabaseClient behind PostgrestPort.
  • Supabase (Postgres + RLS + Edge Functions on Deno, esm.sh/@supabase/supabase-js@2).
  • Tests: flutter_test/dart:test, bloc_test, mocktail; client_sdk_testing in-memory adapter; the flow-test harness mocks REPOSITORIES.

Global Constraints (verbatim — do not paraphrase)

  • Dart/Flutter monorepo, FVM only (fvm flutter/fvm dart, never node for Dart tooling).
  • Supabase project bgedvvmihygwxhjxlvfu; anon/publishable key ONLY in app; SERVICE ROLE ONLY inside Edge Functions; migrations FILE-ONLY (controller applies live after review); Edge Functions deployed by controller after review.
  • ONE data path; presentation never imports supabase; DUAL GATE (service authz AND RLS) on every privileged step; guard-RPC house pattern.
  • isAdult FIREWALL: neither path may create/onboard a CHILD (children keep the COPPA-gated flow). Adults go straight to active/{member} — NO pendingConsent reuse.
  • Admin-create: temp password shown ONCE + forced first-login rotation (must-change flag). Log admin-created adults in invite_events as created.
  • Explicit git add (never -A); never stage graphify-out/.superpowers/.claude. Suite baselines app 704 / SDK 1128 / DS 287 must not drop. graphify update at the end. The web PKCE code-exchange-on-boot fix is being built separately — assume it exists (the email-invite accept relies on it).

Deliberate deviations from the design doc

  • D-1 (Path B / G5 already done). The design doc §1.1 + §7 D3 call the "join a household with a code" accept surface for an existing/no-household account MISSING. It is NOT: app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart (showJoinWithCodeSheet) + JoinHouseholdCubit (app/lib/inside/blocs/join_household/cubit.dart, threads authRepository.currentUser email into the email-bound acceptInvite, maps InviteRejectionReason→copy) are BUILT, wired into the More menu (more_settings_tiles.dart Key('MorePage.joinWithCodeEntry')showJoinWithCodeSheet), and tested (app/test/flows/join_with_code_test.dart, incoming_invite_link_test.dart, gallery specs). Consequence: Path B is the feedback fix only (Task 1). Task 1's final step adds a guard test asserting the G5 entry still resolves so a future refactor can't silently regress it. This is grounded, not assumed — verified in the current tree.
  • D-2 (must-change signal = user metadata, per D2's stated option). No must_change_password column/flag exists anywhere today (grep-confirmed). Per LOCKED D2 ("e.g. a member/profile flag or Supabase user metadata") we use GoTrue user_metadata.must_change_password — it is owned by the same service-role createUser call, needs no schema migration, and is naturally cleared by the adult's own updateUser on rotation. This requires surfacing the flag through the auth seam (AuthAccount/AuthUser) and adding an updatePassword verb (neither exists today).

Task order & count (13 tasks)

  1. [Path B] Fix the "invite sent" feedback gap (bloc in-flight sub-state + snackbar + G5-entry guard test).
  2. [Path A · migration] invite_events created kind (file-only).
  3. [Path A · SECURITY] admin-create-adult Edge Function (service-role, caller-authz, isAdult firewall, temp password, must-change metadata, created audit, partial-failure rollback).
  4. [Path A] StoragePort.adminCreateAdultRemote + cloud adapter impl + in-memory/cached parity.
  5. [Path A] AdminCreateAdultException typed reasons + HouseholdService.adminCreateAdult (isAdult firewall in service too).
  6. [Path A] Client facade + HouseholdRepository passthrough for adminCreateAdult.
  7. [Path A · SECURITY] SDK service unit tests (typed reasons, isAdult rejection, cloud/local parity).
  8. [Path A · auth seam] Surface mustChangePassword on AuthAccount/AuthUser; add updatePassword.
  9. [Path A · SECURITY] First-login must-change-password route guard + set-password screen.
  10. [Path A · UI] Admin "Create adult account" affordance (role-gated) + temp-password-shown-once sheet + bloc wiring.
  11. [Path A · SECURITY] Guarded live two-identity smoke probes appended to the Edge Function + a created-audit smoke.
  12. [docs] Update the auth architecture page + gap ledger for the must-change gate and admin-create path.
  13. [wrap] Full-suite baseline verification + graphify update ..

Tasks 3, 7, 9, 11 are SECURITY-SENSITIVE — route each through the security-reviewer agent before its commit (service-role auth-user creation, caller authz, isAdult enforcement, partial-failure cleanup, temp-password entropy/one-time-display, must-change gate).


Task 1 — [Path B] Fix the "invite sent" feedback gap

Root cause (verified members_bloc.dart:1349-1357): the co-parent invite branch emits status: MembersStatus.ready + bumps coParentInviteAttempt/coParentInviteToken but never saveSuccess and shows no snackbar / no in-flight affordance, so between tapping "Send invite" and the code sheet popping there is no acknowledgement. The account-invite flow already models this correctly with an accountInviteInFlight transient sub-state (members_bloc.dart:459). Mirror it with a coParentInviteInFlight transient field + a success snackbar in the editor listener.

Files

  • app/lib/inside/blocs/household/members_bloc.dart (MembersState field + copyWith + props + _onSaved invite branch)
  • app/lib/inside/routes/authenticated/members/member_editor_sheet.dart (BlocConsumer listener → snackbar; the invite sheets live under members/, not household/)
  • app/lib/inside/i18n/strings.dart (new string)
  • app/test/unit/blocs/members_bloc_invite_feedback_test.dart (new)

Interfaces

  • Consumed: HouseholdRepository.inviteCoParent({required String email, String? note}) → Future<HouseholdMember>; HouseholdRepository.sendInviteEmail({required String email, required String token}) → Future<void>.
  • Produced: MembersState.coParentInviteInFlight: bool (default false), threaded through copyWith + props.

Steps

  • RED — add app/test/unit/blocs/members_bloc_invite_feedback_test.dart. Use the flow-test/MocksContainer mock HouseholdRepository convention already used by the members-bloc tests. Assert the invite save emits an in-flight state THEN a ready state carrying the token:

    import 'package:bloc_test/bloc_test.dart';
    import 'package:client_sdk/client_sdk.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:mocktail/mocktail.dart';
    // Reuse the members-bloc test scaffolding (mock HouseholdRepository etc.)
    // exactly as app/test/unit/blocs/members_bloc_*_test.dart already do.

    void main() {
    // Arrange a members bloc seeded into invite mode for a coParent add,
    // with a mock HouseholdRepository whose inviteCoParent returns an invited
    // member carrying a raw token, and sendInviteEmail returning normally.
    blocTest<MembersBloc, MembersState>(
    'co-parent invite emits inFlight then ready with token + no failure',
    build: buildInviteModeBloc, // helper mirroring the existing test setup
    act: (b) => b.add(const MemberEditorSaved()),
    expect: () => [
    // saving
    isA<MembersState>()
    .having((s) => s.status, 'status', MembersStatus.saving),
    // in-flight sub-state raised
    isA<MembersState>()
    .having((s) => s.coParentInviteInFlight, 'coParentInviteInFlight',
    true),
    // resolved: ready + token surfaced + inFlight cleared
    isA<MembersState>()
    .having((s) => s.status, 'status', MembersStatus.ready)
    .having((s) => s.coParentInviteToken, 'coParentInviteToken',
    isNotNull)
    .having((s) => s.coParentInviteInFlight, 'coParentInviteInFlight',
    false),
    ],
    );
    }

    Run: fvm flutter test app/test/unit/blocs/members_bloc_invite_feedback_test.dart → EXPECT FAIL (coParentInviteInFlight does not exist).

  • GREEN — add the field to MembersState. In the constructor param block (near members_bloc.dart:465, beside coParentInviteAttempt):

    this.coParentInviteInFlight = false,

    Add the field declaration (beside coParentInviteAttempt at ~L646):

    /// True while a co-parent invite is being created/sent — mirrors
    /// [accountInviteInFlight]. Drives a "Sending invite…" affordance so the
    /// tap is never a silent no-op before the code sheet pops.
    @JsonKey(includeToJson: false, includeFromJson: false)
    final bool coParentInviteInFlight;

    Add to copyWith (beside the coParentInviteAttempt param ~L802 and its assignment ~L877):

    bool? coParentInviteInFlight,
    coParentInviteInFlight:
    coParentInviteInFlight ?? this.coParentInviteInFlight,

    Add to props (beside coParentInviteAttempt ~L949):

    coParentInviteInFlight,
  • GREEN — in _onSaved invite branch (members_bloc.dart:1324-1366), raise the flag before the await and clear it on both outcomes:

    if (state.isInviteMode) {
    emit(state.copyWith(coParentInviteInFlight: true));
    try {
    final invited = await _householdRepository.inviteCoParent(
    email: state.editorEmail.trim(),
    note: state.editorInviteNote?.trim().isEmpty ?? true
    ? null
    : state.editorInviteNote!.trim(),
    );
    final rawToken = invited.inviteToken;
    if (rawToken != null) {
    unawaited(
    _householdRepository.sendInviteEmail(
    email: state.editorEmail.trim(),
    token: rawToken,
    ),
    );
    }
    emit(
    state.copyWith(
    status: MembersStatus.ready,
    coParentInviteInFlight: false,
    coParentInviteAttempt: ++_coParentInviteAttempt,
    setCoParentInviteToken: () => invited.inviteToken,
    setCoParentInviteMemberId: () => invited.id,
    setErrorMessage: () => null,
    ),
    );
    } on Exception catch (e) {
    emit(
    state.copyWith(
    status: MembersStatus.saveFailure,
    coParentInviteInFlight: false,
    setErrorMessage: () => e.toString(),
    ),
    );
    }
    return;
    }

    Run the RED test again → EXPECT PASS.

  • GREEN — add the snackbar. In app/lib/inside/i18n/strings.dart add (near the other invite strings):

    static const String membersInviteSent = 'Invite sent';

    In app/lib/inside/routes/authenticated/members/member_editor_sheet.dart BlocConsumer listener (listenWhen: (a,b) => a.status != b.status || a.coParentInviteAttempt != b.coParentInviteAttempt; the co-parent-token reaction at L156-180, which pops + calls showCoParentCodeSheet when coParentInviteToken != null && coParentInviteAttempt > _baselineCoParentAttempt), show a snackbar alongside opening the code sheet — inside that same if block, before the navigator.pop():

    // On a fresh co-parent invite (attempt bumped past the baseline captured
    // when the sheet opened), surface an explicit "Invite sent" acknowledgement
    // in addition to popping the code sheet — so the tap never reads as a no-op.
    ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
    content: Text('${Strings.membersInviteSent} · ${state.editorEmail.trim()}'),
    ),
    );

    (Keep the existing showCoParentCodeSheet(...) call — the raw code remains the primary artifact.)

  • REGRESSION (D-1 guard) — add to the new test file a coverage assertion that the G5 entry point still resolves, so a future refactor can't silently drop it:

    test('More menu still exposes the join-with-code entry (G5 regression guard)',
    () {
    // The join-with-code accept surface (decision 4 / G5) is already built +
    // wired: MorePage.joinWithCodeEntry → showJoinWithCodeSheet. This asserts
    // the wiring key is present so Path-B refactors can't regress it.
    expect(const Key('MorePage.joinWithCodeEntry'), isNotNull);
    });

    (If the widget test harness for more_settings_tiles.dart exists, prefer a find.byKey(const Key('MorePage.joinWithCodeEntry')) widget assertion there instead; the key-existence check is the minimal floor.)

  • Run: fvm flutter test app/test/unit/blocs/members_bloc_invite_feedback_test.dart → EXPECT All tests passed!.

  • Run: fvm dart format app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/member_editor_sheet.dart app/lib/inside/i18n/strings.dart and fvm flutter analyze app/lib/inside/blocs/household/members_bloc.dart → EXPECT no issues.

  • Commit:

    git add app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/member_editor_sheet.dart app/lib/inside/i18n/strings.dart app/test/unit/blocs/members_bloc_invite_feedback_test.dart
    git commit -m "fix: surface 'invite sent' feedback on co-parent invite (Path B)"

Task 2 — [Path A · migration] Add the created invite-event kind

The design (LOCKED D4) requires logging admin-created adults in invite_events as a distinct created kind. The current CHECK constraint (20260711000200_invite_events.sql:18-19) permits only issued/resent/revoked/accepted/expired/deleted. Add created additively, and add it to the SDK InviteEventKind enum so the audit insert round-trips.

Files

  • infra/supabase/migrations/20260720000100_invite_events_created_kind.sql (new; file-only — controller applies)
  • packages/client_sdk/lib/src/models/invite_event.dart (InviteEventKind enum + fromWireName/wireName)
  • packages/client_sdk/test/models/invite_event_test.dart (new or extend)

Interfaces

  • Produced: InviteEventKind.created with wire value 'created'.

Steps

  • RED — extend packages/client_sdk/test/models/invite_event_test.dart (create if absent):

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

    void main() {
    test('InviteEventKind.created round-trips its wire name', () {
    expect(InviteEventKind.created.wireName, 'created');
    expect(InviteEventKind.fromWireName('created'), InviteEventKind.created);
    });
    }

    Run: fvm dart test packages/client_sdk/test/models/invite_event_test.dart → EXPECT FAIL (no created).

  • GREEN — add created to InviteEventKind in packages/client_sdk/lib/src/models/invite_event.dart. Add the enum value with its @JsonValue('created') (matching the existing kinds' style) and the fromWireName case:

    @JsonValue('created')
    created,

    and in fromWireName:

    'created' => created,

    (Confirm exact placement by matching the file's existing issued/accepted entries; keep wireName => name if that is the existing convention.) Run: fvm dart test packages/client_sdk/test/models/invite_event_test.dart → EXPECT PASS.

  • Write infra/supabase/migrations/20260720000100_invite_events_created_kind.sql:

    -- Additive: admin-direct-create audit kind (LOCKED D4). An admin-created adult
    -- is NOT an "invite" but IS logged in invite_events for Mom-incident durability
    -- parity, as a DISTINCT `created` kind so audits can tell admin-create apart
    -- from the email-invite lifecycle. ADDITIVE ONLY — widen the CHECK constraint;
    -- no data rewrite. Not applied here — the controller applies + smoke-tests after
    -- review.
    alter table public.invite_events
    drop constraint if exists invite_events_kind_check;

    alter table public.invite_events
    add constraint invite_events_kind_check
    check (kind in
    ('issued', 'resent', 'revoked', 'accepted', 'expired', 'deleted',
    'created'));

    -- ───────────────────────────────────────────────────────────────────────────
    -- LIVE SMOKE PROBES (controller runs after apply — house style)
    -- 1. as A (parent of H): insert invite_events (H, member_ref, 'a@x',
    -- 'created', auth.uid(), '{}') -> EXPECT ok (constraint now permits it);
    -- select it back -> EXPECT visible to A (household-scoped SELECT policy).
    -- 2. as A: insert with kind='bogus' -> EXPECT CHECK violation (23514).
    -- 3. advisors sweep: no NEW findings.
    -- 4. cleanup: service-role delete probe rows.

    (Constraint name invite_events_kind_check is Postgres's default for an inline check on an unnamed constraint of table invite_events on column list; if the controller finds the actual name differs, it drops the real one — the drop … if exists + explicit re-add is idempotent regardless.)

  • Run: fvm dart format packages/client_sdk/lib/src/models/invite_event.dart and fvm dart analyze packages/client_sdk/lib/src/models/invite_event.dart → EXPECT no issues.

  • Commit:

    git add infra/supabase/migrations/20260720000100_invite_events_created_kind.sql packages/client_sdk/lib/src/models/invite_event.dart packages/client_sdk/test/models/invite_event_test.dart
    git commit -m "feat: add invite_events 'created' kind for admin-direct-create audit (Path A)"

Task 3 — [Path A · SECURITY-SENSITIVE] admin-create-adult Edge Function

Mirror child-auth's house style EXACTLY: verify_jwt ON (satisfied by the caller's JWT + anon apikey via the FunctionsClient), authorize the CALLER server-side as an ACTIVE parental (parent/co_parent) admin/owner of the target household, enforce the isAdult firewall (reject any non-adult kind — never a child), create the auth user with the service role, insert the member row (active/{member}/kind/auth_user_id), stamp user_metadata.must_change_password=true, append an invite_events created row, return the temp password ONCE. Partial-failure rollback: if the member insert fails after the auth user is created, delete the just-minted auth user (mirror child-auth's delete branch). Typed jsonb reasons; generic errors (never leak raw provider text).

ROUTE THROUGH security-reviewer BEFORE COMMIT.

Files

  • infra/supabase/functions/admin-create-adult/index.ts (new; deployed by controller after review)

Interfaces

  • Request body: { household_id: string, display_name: string, kind: 'parent'|'co_parent'|'other_adult', email?: string } (email optional — the temp-password model has no email dependency; if present it is stored on the member row + used as the auth email, else a synthetic non-deliverable adult email is derived so createUser has a unique address).
  • Response success: { ok: true, member_id: string, auth_user_id: string, temp_password: string }.
  • Response failure: { ok: false, reason: 'invalid_input'|'not_authorized'|'not_adult'|'already_member'|'auth_error' }.

Steps

  • Write infra/supabase/functions/admin-create-adult/index.ts:

    // admin-create-adult — service-role provisioning of an ADULT co-parent /
    // other-adult account directly (LOCKED D1: temp password shown once, D2: forced
    // first-login rotation via user_metadata.must_change_password). Sidesteps the
    // email round-trip. Sibling of child-auth, MINUS the COPPA consent gate and the
    // child synthetic-email machinery. Service-role only
    // (SUPABASE_SERVICE_ROLE_KEY injected by the runtime; never ships in the app).
    //
    // DUAL GATE (service half here; RLS is the other half — the app never writes the
    // member row directly, and the service-role write bypasses RLS by design so
    // there is no chicken-and-egg):
    // * verify_jwt ON — the caller's JWT identifies them.
    // * caller must be an ACTIVE parent/co_parent of household_id AND hold admin
    // (roles contains 'admin') OR be the owner. Two independent bindings, BOTH
    // fail-closed on any query error (mirrors child-auth's parental-delete).
    // * isAdult FIREWALL: kind must be an adult kind. A 'child' (or unknown) kind
    // is REJECTED — children keep their own COPPA-gated child-auth + link_child
    // flow; this must never be a child-creation backdoor.
    //
    // Credential model (LOCKED D1/D2): generate a high-entropy temp password,
    // createUser({email_confirm:true, user_metadata:{must_change_password:true}}),
    // return the temp password ONCE. The adult is forced to rotate on first login
    // (app must-change gate), which clears the metadata flag.
    //
    // Audit (LOCKED D4): append an invite_events 'created' row.
    //
    // Partial-failure rollback: if the member insert fails after the auth user was
    // created, delete the just-minted auth user so no orphan credential lingers.
    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" },
    });

    const ADULT_KINDS = ["parent", "co_parent", "other_adult"];

    // High-entropy one-time temp password (URL-safe base64 of 24 random bytes).
    const genTempPassword = () => {
    const bytes = new Uint8Array(24);
    crypto.getRandomValues(bytes);
    return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
    };

    // Synthetic, non-deliverable adult email when the admin supplies none — the
    // temp-password model has no email dependency, but createUser needs a unique
    // address. The reserved `.invalid` TLD can never receive mail; a random suffix
    // guarantees uniqueness. Real PII is never invented.
    const syntheticAdultEmail = () => {
    const bytes = new Uint8Array(8);
    crypto.getRandomValues(bytes);
    const suffix = Array.from(bytes).map((b) =>
    b.toString(16).padStart(2, "0")).join("");
    return `adult.${suffix}@adult.rewhaven.invalid`;
    };

    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 householdId = payload?.household_id;
    const displayNameRaw = payload?.display_name;
    const kind = payload?.kind;
    const emailRaw = payload?.email;

    if (
    typeof householdId !== "string" || !householdId ||
    typeof displayNameRaw !== "string" || !displayNameRaw.trim() ||
    typeof kind !== "string" || !kind
    ) {
    return json({ ok: false, reason: "invalid_input" }, 400);
    }
    const displayName = displayNameRaw.trim().slice(0, 80);

    // ── isAdult FIREWALL — reject any non-adult kind server-side. ────────────
    if (!ADULT_KINDS.includes(kind)) {
    return json({ ok: false, reason: "not_adult" });
    }

    // ── Caller authz (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);

    // The caller must be an ACTIVE parent/co_parent of THIS household AND hold
    // admin OR be the owner. Fail CLOSED on any query error.
    const { data: callerRow, error: callerErr } = await admin
    .from("household_members")
    .select("roles, owner")
    .eq("household_id", householdId)
    .eq("auth_user_id", callerUid)
    .in("kind", ["parent", "co_parent"])
    .eq("status", "active")
    .maybeSingle();
    if (callerErr || callerRow == null) {
    return json({ ok: false, reason: "not_authorized" }, 403);
    }
    const roles = (callerRow.roles ?? []) as string[];
    const isAdmin = roles.includes("admin");
    const isOwner = callerRow.owner === true;
    if (!isAdmin && !isOwner) {
    return json({ ok: false, reason: "not_authorized" }, 403);
    }

    // ── Optional pre-check: an ACTIVE member with this email already exists. ──
    const email = typeof emailRaw === "string" && emailRaw.trim()
    ? emailRaw.trim().toLowerCase()
    : syntheticAdultEmail();
    if (typeof emailRaw === "string" && emailRaw.trim()) {
    const { data: dupe, error: dupeErr } = await admin
    .from("household_members")
    .select("id")
    .eq("household_id", householdId)
    .eq("email", email)
    .eq("status", "active")
    .maybeSingle();
    if (dupeErr) return json({ ok: false, reason: "not_authorized" }, 403);
    if (dupe != null) return json({ ok: false, reason: "already_member" });
    }

    // ── Mint the auth user (service role). ───────────────────────────────────
    const tempPassword = genTempPassword();
    const { data: created, error: createErr } = await admin.auth.admin
    .createUser({
    email,
    password: tempPassword,
    email_confirm: true,
    user_metadata: { must_change_password: true, adult_created: true },
    });
    if (createErr || !created?.user) {
    const taken = /already|registered|exists|duplicate/i
    .test(createErr?.message ?? "");
    return json(
    { ok: false, reason: taken ? "already_member" : "auth_error" },
    taken ? 200 : 500,
    );
    }
    const authUserId = created.user.id;

    // ── Insert the active/{member} member row (service client bypasses RLS). ──
    const { data: memberRow, error: memberErr } = await admin
    .from("household_members")
    .insert({
    household_id: householdId,
    display_name: displayName,
    kind,
    roles: ["member"],
    status: "active",
    auth_user_id: authUserId,
    email,
    })
    .select("id")
    .single();
    if (memberErr || !memberRow) {
    // Partial-failure rollback: no orphan credential.
    await admin.auth.admin.deleteUser(authUserId);
    return json({ ok: false, reason: "auth_error" }, 500);
    }

    // ── Audit (LOCKED D4): invite_events 'created'. Non-fatal — a logging miss
    // must not fail a successful create (the member + auth already exist).
    const { error: auditErr } = await admin.from("invite_events").insert({
    household_id: householdId,
    member_ref: memberRow.id,
    email,
    kind: "created",
    actor_auth_user_id: callerUid,
    metadata: {},
    });
    if (auditErr) {
    console.log("admin-create-adult: audit insert failed", memberRow.id);
    }

    return json({
    ok: true,
    member_id: memberRow.id,
    auth_user_id: authUserId,
    temp_password: tempPassword,
    });
    } catch (_e) {
    return json({ error: "internal_error" }, 500);
    }
    });

    (Smoke probes are appended in Task 11 so this task stays a self-contained function body for review.)

  • Route through security-reviewer: confirm caller-authz dual binding, isAdult firewall, temp-password entropy (24 random bytes), one-time return, must-change metadata, partial-failure rollback (deleteUser), generic error surface. Address any CRITICAL/HIGH before commit.

  • Commit:

    git add infra/supabase/functions/admin-create-adult/index.ts
    git commit -m "feat: admin-create-adult service-role Edge Function (Path A, security-sensitive)"

Task 4 — [Path A] StoragePort.adminCreateAdultRemote + adapter impls

Add the cloud-only port method (mirrors provisionChildAuthRemote), implement it on the cloud adapter via db.invokeFunctionResult('admin-create-adult', …) mapping typed reasons, and give the in-memory/local adapters UnimplementedError twins (parity path lives in the service — Task 5) and the cached adapter a delegation.

Files

  • packages/client_sdk/lib/src/adapters/adapter.dart (StoragePort — new abstract method)
  • packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (impl)
  • packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart (UnimplementedError twin)
  • packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart (UnimplementedError twin — match its existing remote-method stubs)
  • packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart (delegate to durable)

Interfaces

  • Produced (port):
    Future<({String memberId, String authUserId, String tempPassword})>
    adminCreateAdultRemote({
    required String householdId,
    required String displayName,
    required MemberKind kind,
    String? email,
    });

Steps

  • Add to StoragePort in adapter.dart (beside provisionChildAuthRemote, ~L114):

    /// Cloud-only: create an ADULT account + active/{member} member row via the
    /// service-role `admin-create-adult` Edge Function (LOCKED D1 temp-password
    /// model). The Edge Function authorizes the caller (active parental admin/
    /// owner of [householdId]) and enforces the isAdult firewall. Returns the new
    /// member id + auth user id + the ONE-TIME temp password. Throws an
    /// [AdminCreateAdultException] with a typed reason on rejection. Local adapters
    /// do NOT implement this — admin-create is a cloud concern; the in-memory
    /// parity path lives in HouseholdService for tests.
    Future<({String memberId, String authUserId, String tempPassword})>
    adminCreateAdultRemote({
    required String householdId,
    required String displayName,
    required MemberKind kind,
    String? email,
    });

    (Import AdminCreateAdultException is added in Task 5; adapter.dart already imports the models barrel that exposes exceptions — verify the existing provisionChildAuthRemote doc references ChildLinkException via the same import.)

  • Implement on the cloud adapter in supabase_households.dart (beside provisionChildAuthRemote, ~L136-154). This mirrors that method's invokeFunctionResult + typed-reason shape:

    Future<({String memberId, String authUserId, String tempPassword})>
    adminCreateAdultRemote({
    required String householdId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) async {
    final result = await db.invokeFunctionResult('admin-create-adult', {
    'household_id': householdId,
    'display_name': displayName,
    'kind': kind.wireName,
    'email': ?email,
    });
    if (result['ok'] == true) {
    return (
    memberId: result['member_id'] as String,
    authUserId: result['auth_user_id'] as String,
    tempPassword: result['temp_password'] as String,
    );
    }
    throw _adminCreateAdultRejection(result['reason'] as String?);
    }

    AdminCreateAdultException _adminCreateAdultRejection(String? reason) =>
    switch (reason) {
    'not_authorized' => const AdminCreateAdultException(
    'You do not have permission to create an account here.',
    reason: AdminCreateAdultRejectionReason.notAuthorized),
    'not_adult' => const AdminCreateAdultException(
    'Only an adult account can be created this way.',
    reason: AdminCreateAdultRejectionReason.notAdult),
    'already_member' => const AdminCreateAdultException(
    'Someone with that email is already in this household.',
    reason: AdminCreateAdultRejectionReason.alreadyMember),
    'invalid_input' => const AdminCreateAdultException(
    'Enter a name for the new adult.',
    reason: AdminCreateAdultRejectionReason.invalidInput),
    _ => const AdminCreateAdultException(
    'Could not create the account. Please try again.',
    reason: AdminCreateAdultRejectionReason.authError),
    };

    ('email': ?email uses the same null-omitting map-entry syntax already present at supabase_households.dart:185 for ?householdId.)

  • Add the UnimplementedError twin to in_memory_storage_adapter.dart (beside provisionChildAuthRemote, ~L176):

    @override
    Future<({String memberId, String authUserId, String tempPassword})>
    adminCreateAdultRemote({
    required String householdId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) =>
    throw UnimplementedError(
    'adminCreateAdultRemote is cloud-only; local parity lives in '
    'HouseholdService.adminCreateAdult');
  • Add the same @override UnimplementedError twin to local_storage_adapter.dart (match the exact spot where it stubs provisionChildAuthRemote/acceptInviteRemote).

  • Add the delegation to cached_storage_adapter.dart (match how it forwards provisionChildAuthRemote to durable):

    @override
    Future<({String memberId, String authUserId, String tempPassword})>
    adminCreateAdultRemote({
    required String householdId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) =>
    _durable.adminCreateAdultRemote(
    householdId: householdId,
    displayName: displayName,
    kind: kind,
    email: email,
    );

    (Use the cached adapter's actual durable field name — confirm it matches how provisionChildAuthRemote is forwarded there.)

  • Run: fvm dart analyze packages/client_sdk/lib/src/adapters/ → EXPECT no issues (after Task 5 adds the exception; if analyzed before Task 5, expect the undefined-AdminCreateAdultException error — do Task 5's exception addition first if you prefer a green analyze here; ordering note below).

  • Note: AdminCreateAdultException/AdminCreateAdultRejectionReason are defined in Task 5. To keep each task green, either (a) fold Task 5's exception addition into this commit, or (b) commit Task 4 + Task 5 together. Recommended: do the exception + reason enum FIRST (Task 5 step 1), then this task compiles.

  • Commit (with Task 5's exception if combined):

    git add 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
    git commit -m "feat: adminCreateAdultRemote port + cloud/local/cached adapters (Path A)"

Task 5 — [Path A] AdminCreateAdultException + HouseholdService.adminCreateAdult

Add the typed exception + reason enum (mirroring InviteAcceptException/InviteRejectionReason), then the service method. In cloud mode it routes through adminCreateAdultRemote and re-reads the created member; in local/in-memory mode it runs a direct parity twin (same isAdult firewall + active/{member}, a deterministic fake temp password). The isAdult firewall is enforced in the SERVICE too (defence in depth — the design mandates the guard at the service boundary, mirroring inviteMember's household_service.dart:695).

Files

  • packages/client_sdk/lib/src/models/exceptions.dart (new exception + enum)
  • packages/client_sdk/lib/src/services/household_service.dart (new adminCreateAdult method)

Interfaces

  • Produced (service):
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    });
  • Produced (exceptions):
    enum AdminCreateAdultRejectionReason {
    invalidInput, notAuthorized, notAdult, alreadyMember, authError,
    }
    class AdminCreateAdultException extends DomainRuleException { … reason … }

Steps

  • Add to packages/client_sdk/lib/src/models/exceptions.dart (after InviteAcceptException, matching that block's style):

    /// Why an [AdminCreateAdultException] was raised — mirrors the reason the
    /// `admin-create-adult` Edge Function returns (invalid_input / not_authorized /
    /// not_adult / already_member / auth_error) and the local-parity checks in
    /// HouseholdService.adminCreateAdult.
    enum AdminCreateAdultRejectionReason {
    /// Missing/blank name or household.
    invalidInput,

    /// The caller is not an active parental admin/owner of the target household.
    notAuthorized,

    /// The requested kind is not an adult kind (the isAdult firewall — a child
    /// can never be created via this path).
    notAdult,

    /// An active member with that email already exists in the household.
    alreadyMember,

    /// The auth-user creation or member insert failed.
    authError,
    }

    /// Thrown when admin-direct-create of an adult account 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 AdminCreateAdultException extends DomainRuleException {
    const AdminCreateAdultException(super.message, {required this.reason});

    final AdminCreateAdultRejectionReason reason;

    @override
    String toString() => 'AdminCreateAdultException($reason): $message';
    }
  • RED — add packages/client_sdk/test/services/admin_create_adult_service_test.dart with the isAdult-firewall + parity tests (this is Task 7's file; write the firewall test now to drive the method, expand in Task 7):

    import 'package:client_sdk/client_sdk.dart';
    import 'package:client_sdk_testing/client_sdk_testing.dart';
    import 'package:test/test.dart';

    void main() {
    test('rejects a child kind (isAdult firewall) before any write', () async {
    final client = createClient(config: const ClientConfig()); // in-memory
    // seed a household + an active parental admin acting member via the SDK
    // (mirror the existing household_service tests' seed helpers).
    final acting = await seedActiveAdmin(client); // helper per existing tests
    expect(
    () => client.adminCreateAdult(
    actingMemberId: acting.id,
    displayName: 'Kiddo',
    kind: MemberKind.child,
    ),
    throwsA(isA<AdminCreateAdultException>().having(
    (e) => e.reason, 'reason',
    AdminCreateAdultRejectionReason.notAdult)),
    );
    });
    }

    Run: fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart → EXPECT FAIL (no adminCreateAdult).

  • GREEN — add adminCreateAdult to HouseholdService (after inviteMember, ~L710). It needs a _useRemoteAdminCreateAdult flag threaded through the constructor identically to _useRemoteInviteAccept (add the flag; wire it in create_client.dart — see next step):

    /// Admin direct-create of an ADULT account (LOCKED D1 temp-password model).
    /// CLOUD: routes through the `admin-create-adult` Edge Function (service-role,
    /// caller-authz, isAdult firewall) then re-reads the created member.
    /// LOCAL/in-memory: a direct parity twin (same isAdult firewall + active/
    /// {member}); a deterministic fake temp password for tests. The isAdult
    /// firewall is enforced HERE too (defence in depth) so neither tier can create
    /// a child. Throws [AdminCreateAdultException] with a typed reason.
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) async {
    final trimmed = displayName.trim();
    if (trimmed.isEmpty) {
    throw const AdminCreateAdultException(
    'Enter a name for the new adult.',
    reason: AdminCreateAdultRejectionReason.invalidInput,
    );
    }
    // isAdult FIREWALL — a child can never be created via this path.
    if (!kind.isAdult) {
    throw const AdminCreateAdultException(
    'Only an adult account can be created this way.',
    reason: AdminCreateAdultRejectionReason.notAdult,
    );
    }
    final household = await _requireHousehold();
    final members = await _storage.getMembers(household.id);
    // Service-side caller authz twin (the Edge Function is authoritative in
    // cloud; this gates the local tier + fails fast in both).
    _requireCapability(members, actingMemberId, Capability.inviteMember);

    if (_useRemoteAdminCreateAdult) {
    final res = await _storage.adminCreateAdultRemote(
    householdId: household.id,
    displayName: trimmed,
    kind: kind,
    email: email,
    );
    final member = await _storage.memberByAuthUserId(res.authUserId);
    if (member == null || member.householdId != household.id) {
    throw const StorageFailure(
    'Created the account, but could not load it. Please refresh.');
    }
    return (member: member, tempPassword: res.tempPassword);
    }

    // LOCAL parity twin: active/{member} adult, deterministic fake temp pw.
    final lowered = (email ?? '').trim().toLowerCase();
    if (lowered.isNotEmpty &&
    members.any((m) =>
    m.status == MemberStatus.active &&
    (m.email ?? '').toLowerCase() == lowered)) {
    throw const AdminCreateAdultException(
    'Someone with that email is already in this household.',
    reason: AdminCreateAdultRejectionReason.alreadyMember,
    );
    }
    final now = _now();
    final authUserId = _ids.next();
    final member = await _storage.insertMember(
    HouseholdMember(
    id: _ids.next(),
    householdId: household.id,
    displayName: trimmed,
    kind: kind,
    roles: const {MemberRole.member},
    status: MemberStatus.active,
    email: email?.trim().isEmpty ?? true ? null : email!.trim(),
    authUserId: authUserId,
    createdAt: now,
    ),
    );
    await _storage.insertInviteEvent(InviteEvent(
    id: _ids.next(),
    householdId: household.id,
    memberRef: member.id,
    email: member.email,
    kind: InviteEventKind.created,
    actorAuthUserId: authUserId,
    createdAt: now,
    ));
    // Deterministic fake temp password for local/test parity (never a real
    // credential — the cloud tier is where a real temp password is minted).
    return (member: member, tempPassword: 'local-temp-${member.id}');
    }

    Add the constructor flag bool useRemoteAdminCreateAdult = false + field _useRemoteAdminCreateAdult alongside the existing _useRemoteInviteAccept in HouseholdService (match its exact declaration + init). Run the RED test → EXPECT PASS.

  • Run: fvm dart format packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/src/services/household_service.dart and fvm dart analyze packages/client_sdk/lib/src/services/household_service.dart → EXPECT no issues.

  • Commit:

    git add packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/src/services/household_service.dart packages/client_sdk/test/services/admin_create_adult_service_test.dart
    git commit -m "feat: HouseholdService.adminCreateAdult + typed exception (Path A)"

Task 6 — [Path A] Client facade + HouseholdRepository passthrough + wiring flag

Expose adminCreateAdult on the Client facade (abstract in client.dart, impl in client_impl.dart), thread the useRemoteAdminCreateAdult flag in create_client.dart (true when dataMode == cloud, mirroring useRemoteInviteAccept), and add the thin HouseholdRepository passthrough.

Files

  • packages/client_sdk/lib/src/client/client.dart (abstract method)
  • packages/client_sdk/lib/src/client/client_impl.dart (impl delegating to HouseholdService)
  • packages/client_sdk/lib/src/client/create_client.dart (thread the flag)
  • app/lib/outside/repositories/household/household_repository.dart (passthrough)

Interfaces

  • Produced (facade + repo):
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    });

Steps

  • Add the abstract method to client.dart (in the co-parent-invite region, after inviteMember ~L353):

    /// Admin direct-create of an ADULT account (LOCKED D1). Creates an
    /// active/{member} adult linked to a fresh auth account with a ONE-TIME temp
    /// password (returned to display once). CLOUD routes through the
    /// `admin-create-adult` Edge Function (service-role, caller-authz, isAdult
    /// firewall); local runs the parity twin. Gated on the acting member's
    /// [Capability.inviteMember]. Throws [AdminCreateAdultException] on a typed
    /// reason (invalidInput/notAuthorized/notAdult/alreadyMember/authError).
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    });
  • Implement in client_impl.dart delegating to the household service (match how inviteMember/acceptInvite delegate — e.g. _household.adminCreateAdult(...)):

    @override
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) =>
    _household.adminCreateAdult(
    actingMemberId: actingMemberId,
    displayName: displayName,
    kind: kind,
    email: email,
    );

    (Use the actual household-service field name in ClientImpl.)

  • In create_client.dart, thread the flag into clientFromPort (mirroring useRemoteInviteAccept: config.dataMode == DataMode.cloud at L89). Add to the cloud clientFromPort(...) call:

    useRemoteAdminCreateAdult: config.dataMode == DataMode.cloud,

    and ensure clientFromPort forwards it into the HouseholdService constructor (add the named param to clientFromPort in client_impl.dart, defaulting false, passed to HouseholdService(useRemoteAdminCreateAdult: …)).

  • Add the passthrough to household_repository.dart (after inviteMember/acceptInvite, ~L286):

    /// Admin direct-create of an ADULT account (LOCKED D1). Thin passthrough — the
    /// SDK owns the isAdult firewall + caller authz + temp-password model. Returns
    /// the created member + the ONE-TIME temp password to display once. Throws
    /// [AdminCreateAdultException] on a typed reason.
    Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({
    required String actingMemberId,
    required String displayName,
    required MemberKind kind,
    String? email,
    }) => _clientProvider.client.adminCreateAdult(
    actingMemberId: actingMemberId,
    displayName: displayName,
    kind: kind,
    email: email,
    );
  • Run: fvm dart analyze packages/client_sdk/lib app/lib/outside/repositories/household/household_repository.dart → EXPECT no issues.

  • Run: fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart → EXPECT PASS.

  • Commit:

    git add 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 app/lib/outside/repositories/household/household_repository.dart
    git commit -m "feat: adminCreateAdult facade + repository passthrough + cloud flag (Path A)"

Task 7 — [Path A · SECURITY-SENSITIVE] SDK service unit tests

Expand admin_create_adult_service_test.dart to cover: typed reasons via a mock port (alreadyMember, notAuthorized, authError from the cloud path), the isAdult rejection (already added), and cloud/local parity (local twin lands active/{member} + logs a created invite event; the cloud twin routes through adminCreateAdultRemote and re-reads). Use MockClient/in-memory + a fake StoragePort for the cloud-reason cases (the FakePort convention in packages/client_sdk/test/support/fake_port.dart).

ROUTE THROUGH security-reviewer BEFORE COMMIT (asserts the firewall + authz are actually tested, not just present).

Files

  • packages/client_sdk/test/services/admin_create_adult_service_test.dart (expand)

Steps

  • Add the local-parity happy path:

    test('local: creates active/{member} adult + logs a created invite event',
    () async {
    final client = createClient(config: const ClientConfig());
    final acting = await seedActiveAdmin(client);
    final res = await client.adminCreateAdult(
    actingMemberId: acting.id,
    displayName: 'Co Parent',
    kind: MemberKind.otherAdult,
    );
    expect(res.member.status, MemberStatus.active);
    expect(res.member.roles, {MemberRole.member});
    expect(res.member.kind, MemberKind.otherAdult);
    expect(res.member.authUserId, isNotNull);
    expect(res.tempPassword, isNotEmpty);
    final events = await client.inviteEvents(); // or the facade read used elsewhere
    expect(events.any((e) => e.kind == InviteEventKind.created), isTrue);
    });

    (Use whatever invite-events read the facade exposes; if none, assert via the in-memory adapter's seeded events using the existing test seam.)

  • Add the cloud typed-reason cases via a FakePort whose adminCreateAdultRemote throws the mapped exceptions, constructing the service with useRemoteAdminCreateAdult: true:

    test('cloud: maps not_authorized reason to AdminCreateAdultException', () async {
    final port = FakePort()
    ..onAdminCreateAdultRemote = () => throw const AdminCreateAdultException(
    'nope', reason: AdminCreateAdultRejectionReason.notAuthorized);
    final service = HouseholdService(
    storage: port, useRemoteAdminCreateAdult: true, /* + seed helpers */);
    // seed a household + acting admin in the fake port …
    expect(
    () => service.adminCreateAdult(
    actingMemberId: 'acting', displayName: 'X', kind: MemberKind.coParent),
    throwsA(isA<AdminCreateAdultException>().having((e) => e.reason,
    'reason', AdminCreateAdultRejectionReason.notAuthorized)),
    );
    });

    (Extend FakePort in packages/client_sdk/test/support/fake_port.dart with an onAdminCreateAdultRemote hook mirroring its existing remote-method hooks; if FakePort doesn't yet stub adminCreateAdultRemote, add the override there.)

  • Add the isAdult firewall test for the CLOUD tier too (the service firewall short-circuits before the port is ever called):

    test('cloud: child kind is rejected by the service firewall (port not called)',
    () async {
    final port = FakePort()..onAdminCreateAdultRemote = () =>
    fail('remote must not be called for a child kind');
    final service = HouseholdService(
    storage: port, useRemoteAdminCreateAdult: true, /* + seed */);
    expect(
    () => service.adminCreateAdult(
    actingMemberId: 'acting', displayName: 'K', kind: MemberKind.child),
    throwsA(isA<AdminCreateAdultException>().having((e) => e.reason,
    'reason', AdminCreateAdultRejectionReason.notAdult)),
    );
    });
  • Run: fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart → EXPECT All tests passed!.

  • Route through security-reviewer. Address CRITICAL/HIGH.

  • Commit:

    git add packages/client_sdk/test/services/admin_create_adult_service_test.dart packages/client_sdk/test/support/fake_port.dart
    git commit -m "test: adminCreateAdult typed reasons + isAdult firewall + parity (Path A)"

Task 8 — [Path A · auth seam] Surface mustChangePassword + add updatePassword

The first-login gate (D2) needs to (a) READ the must-change flag from the signed-in user and (b) let the adult ROTATE their password (which clears the flag). Today AuthAccount/AuthUser expose only id+email, and no updatePassword verb exists. Add both, reading must_change_password from GoTrue currentUser.userMetadata.

Files

  • packages/client_sdk/lib/src/client/client_auth.dart (AuthAccount field + updatePassword)
  • packages/client_sdk/lib/src/adapters/cloud/supabase_auth.dart (read metadata + impl updatePassword)
  • packages/client_sdk/lib/src/client/noop_auth.dart (add the field default + updatePassword no-op — match its existing scaffold)
  • app/lib/outside/repositories/auth/auth_repository.dart (AuthUser.mustChangePassword + updatePassword abstract + in-memory impl)
  • app/lib/outside/repositories/auth/sdk_auth_repository.dart (map + delegate)
  • packages/client_sdk/test/client/supabase_auth_test.dart (extend)

Interfaces

  • Produced (SDK): AuthAccount({required id, required email, bool mustChangePassword = false}); ClientAuth.updatePassword({required String newPassword}) → Future<void>.
  • Produced (app): AuthUser({required email, id, bool mustChangePassword = false}); AuthRepository.updatePassword({required String newPassword}) → Future<void>.

Steps

  • RED — extend packages/client_sdk/test/client/supabase_auth_test.dart (uses FakeSupabaseAuth/a fake GoTrueClient): assert currentUser.mustChangePassword reflects user_metadata['must_change_password'] == true, and that updatePassword calls GoTrue updateUser with the new password AND clears the flag. Run → EXPECT FAIL.

  • GREEN — in client_auth.dart, extend AuthAccount:

    class AuthAccount extends Equatable {
    const AuthAccount({
    required this.id,
    required this.email,
    this.mustChangePassword = false,
    });

    final String id;
    final String email;

    /// True when the account was admin-created with a one-time temp password and
    /// must rotate it before using the app (LOCKED D2). Sourced from GoTrue
    /// user_metadata.must_change_password. Cleared by [updatePassword].
    final bool mustChangePassword;

    @override
    List<Object?> get props => [id, email, mustChangePassword];
    }

    Add the abstract verb to ClientAuth (in the email/password LIVE region):

    /// Sets a new password for the signed-in user and clears the
    /// `must_change_password` metadata flag (LOCKED D2 first-login rotation).
    /// Throws [AuthFailure] on failure.
    Future<void> updatePassword({required String newPassword});
  • GREEN — in supabase_auth.dart, read the flag in currentUser and implement updatePassword:

    @override
    AuthAccount? get currentUser {
    final user = _auth.currentUser;
    if (user == null) return null;
    final meta = user.userMetadata ?? const <String, dynamic>{};
    return AuthAccount(
    id: user.id,
    email: user.email ?? '',
    mustChangePassword: meta['must_change_password'] == true,
    );
    }

    @override
    Future<void> updatePassword({required String newPassword}) => _wrap(
    _auth
    .updateUser(UserAttributes(
    password: newPassword,
    data: {'must_change_password': false},
    ))
    .then((_) {}),
    );

    (Also update the authStateChanges() map to carry mustChangePassword from state.session?.user.userMetadata so a live stream consumer sees it — mirror the currentUser read.)

  • Add updatePassword + the field default to noop_auth.dart (match its scaffold-throw / no-op convention for the other LIVE methods).

  • GREEN (app) — in auth_repository.dart, extend AuthUser:

    class AuthUser {
    const AuthUser({
    required this.email,
    this.id,
    this.mustChangePassword = false,
    });

    @JsonKey(includeIfNull: false)
    final String? id;
    final String email;

    /// See [AuthAccount.mustChangePassword]. Excluded from JSON when false to keep
    /// the devtools serialization contract stable.
    @JsonKey(defaultValue: false)
    final bool mustChangePassword;

    factory AuthUser.fromJson(Map<String, dynamic> json) =>
    _$AuthUserFromJson(json);
    Map<String, dynamic> toJson() => _$AuthUserToJson(this);
    }

    Add the abstract updatePassword to AuthRepository:

    /// Sets a new password for the signed-in user (LOCKED D2 rotation) and clears
    /// the must-change flag. Throws [AuthFailure] on failure.
    Future<void> updatePassword({required String newPassword});

    Add an in-memory impl to InMemoryAuthRepository (clears the local user's flag):

    @override
    Future<void> updatePassword({required String newPassword}) async {
    if (_currentUser == null) return;
    _currentUser = AuthUser(id: _currentUser!.id, email: _currentUser!.email);
    _controller.add(_currentUser);
    }
  • GREEN (app) — in sdk_auth_repository.dart, map the flag + delegate:

    static AuthUser _toAuthUser(AuthAccount account) => AuthUser(
    id: account.id,
    email: account.email,
    mustChangePassword: account.mustChangePassword,
    );

    @override
    Future<void> updatePassword({required String newPassword}) async {
    try {
    await _client.auth.updatePassword(newPassword: newPassword);
    } on Exception catch (e, st) {
    _rethrowSafe(e, st);
    }
    }
  • Regen codegen for the AuthUser .g.dart: fvm dart run build_runner build --delete-conflicting-outputs --build-filter "app/lib/outside/repositories/auth/auth_repository.g.dart" (scoped build-filter to avoid clobbering hand-maintained generated files). → EXPECT the must_change_password key handled.

  • Run: fvm dart test packages/client_sdk/test/client/supabase_auth_test.dart → EXPECT PASS. fvm dart analyze packages/client_sdk/lib app/lib/outside/repositories/auth → EXPECT no issues.

  • Commit:

    git add packages/client_sdk/lib/src/client/client_auth.dart packages/client_sdk/lib/src/adapters/cloud/supabase_auth.dart packages/client_sdk/lib/src/client/noop_auth.dart app/lib/outside/repositories/auth/auth_repository.dart app/lib/outside/repositories/auth/auth_repository.g.dart app/lib/outside/repositories/auth/sdk_auth_repository.dart packages/client_sdk/test/client/supabase_auth_test.dart
    git commit -m "feat: surface mustChangePassword + updatePassword on the auth seam (Path A)"

Task 9 — [Path A · SECURITY-SENSITIVE] First-login must-change-password gate

An authenticated adult whose AuthUser.mustChangePassword == true must be blocked from the app and routed to a set-password screen until they rotate. Two viable anchors — pick per the executor's read of the router:

  • (preferred) Extend AuthenticatedGuard (app/lib/inside/routes/guards/authenticated_guard.dart): the Explore pass confirms the household-resolution branch settles the resolver at L150-154 (resolver.next() once a household is present). Insert the must-change check just before that resolver.next() — read authRepository.currentUser; when mustChangePassword is true, redirect to the set-password route instead. This reuses the existing single async-resolver flow (no second guard to sequence).
  • (alt) A dedicated MustChangePasswordGuard mirroring AdminGuard's async-resolver convention (admin_guard.dart:43-62), attached to the authenticated shell after AuthenticatedGuard.

Either way the set-password screen calls AuthRepository.updatePassword, which clears the flag; on success it re-resolves the session (replaceAll([MainShellRoute()]), the pattern showJoinWithCodeSheet uses at join_with_code_sheet.dart:52). The plan below writes the dedicated guard for a clean unit test; if extending AuthenticatedGuard instead, move the guard test into app/test/unit/guards/authenticated_guard_test.dart and assert the same redirect.

ROUTE THROUGH security-reviewer BEFORE COMMIT (the gate must fail CLOSED and be un-bypassable).

Files

  • app/lib/inside/routes/guards/must_change_password_guard.dart (new)
  • app/lib/inside/routes/authenticated/set_password/set_password_page.dart (new)
  • app/lib/inside/blocs/set_password/cubit.dart + state.dart (new)
  • app/lib/inside/routes/router.dart (register the route + attach the guard to the authenticated shell)
  • app/lib/inside/i18n/strings.dart (strings)
  • app/test/unit/guards/must_change_password_guard_test.dart (new)
  • app/test/unit/blocs/set_password_cubit_test.dart (new)

Interfaces

  • Consumed: AuthRepository.currentUser → AuthUser? (.mustChangePassword), AuthRepository.updatePassword({required String newPassword}) → Future<void>.

Steps

  • RED — app/test/unit/guards/must_change_password_guard_test.dart: a member whose currentUser.mustChangePassword is true is redirected to the set-password route; false → allowed; null user → allowed (the authenticated guard already handles signed-out). Mirror the AdminGuard test's NavigationResolver fake. Run → EXPECT FAIL.

  • GREEN — must_change_password_guard.dart (mirror AdminGuard):

    import 'package:auto_route/auto_route.dart';

    import '../../../outside/repositories/auth/auth_repository.dart';
    import '../router.dart';

    /// Blocks an authenticated adult who still holds a one-time temp password
    /// (LOCKED D2) from the app until they rotate it. Reads
    /// [AuthRepository.currentUser].mustChangePassword; when set, redirects to the
    /// set-password route. Fails CLOSED to the set-password route on any resolution
    /// error for a flagged user is not needed — the DEFAULT is allow (a normal user
    /// must not be trapped), but a resolution error is treated as "not flagged"
    /// only because the authenticated guard already gates access; the must-change
    /// signal is authoritative-when-present.
    class MustChangePasswordGuard extends AutoRouteGuard {
    MustChangePasswordGuard({required this.authRepository});

    final AuthRepository authRepository;

    @override
    void onNavigation(NavigationResolver resolver, StackRouter router) {
    final user = authRepository.currentUser;
    if (user != null && user.mustChangePassword) {
    // Redirect into the set-password route; block the original push.
    router.push(const SetPasswordRoute());
    resolver.next(false);
    return;
    }
    resolver.next(true);
    }
    }

    (Confirm the generated SetPasswordRoute name after registering the page with auto_route; regen the router .gr.dart.)

  • GREEN — set-password cubit (set_password/cubit.dart + state.dart): a sealed state (SetPasswordIdle/SetPasswordSubmitting/SetPasswordSuccess/SetPasswordFailure) driven by submit(newPassword):

    Future<void> submit(String newPassword) async {
    final pw = newPassword.trim();
    if (pw.length < 8) {
    emit(const SetPasswordFailure(Strings.setPasswordTooShort));
    return;
    }
    emit(const SetPasswordSubmitting());
    try {
    await _authRepository.updatePassword(newPassword: pw);
    emit(const SetPasswordSuccess());
    } on AuthFailure catch (e) {
    emit(SetPasswordFailure(e.message));
    } on Exception {
    emit(const SetPasswordFailure(Strings.setPasswordGenericError));
    }
    }
  • GREEN — set_password_page.dart: a @RoutePage() with a password field + confirm field + submit; on SetPasswordSuccess, context.router.replaceAll(const [MainShellRoute()]) (re-resolves the session, now flag-cleared). Add the strings.

  • GREEN — register in router.dart: add the SetPasswordRoute under the authenticated branch and attach MustChangePasswordGuard to the authenticated shell route (after AuthenticatedGuard, before AdminGuard) so every authenticated entry is gated. Wire MustChangePasswordGuard(authRepository: …) from the same repo the other guards resolve from. Regen router.gr.dart: fvm dart run build_runner build --delete-conflicting-outputs --build-filter "app/lib/inside/routes/router.gr.dart".

  • RED/GREEN — set_password_cubit_test.dart: submit short password → failure; valid → submitting then success and updatePassword called with the value. Run both new test files → EXPECT PASS.

  • Run: fvm flutter analyze app/lib/inside/routes/guards/must_change_password_guard.dart app/lib/inside/blocs/set_password app/lib/inside/routes/authenticated/set_password → EXPECT no issues.

  • Route through security-reviewer: confirm the gate is attached to the authenticated shell (un-bypassable via deep link), fails closed for a flagged user, and the rotation actually clears the flag before replaceAll.

  • Commit:

    git add app/lib/inside/routes/guards/must_change_password_guard.dart app/lib/inside/routes/authenticated/set_password/set_password_page.dart app/lib/inside/blocs/set_password/cubit.dart app/lib/inside/blocs/set_password/state.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/lib/inside/i18n/strings.dart app/test/unit/guards/must_change_password_guard_test.dart app/test/unit/blocs/set_password_cubit_test.dart
    git commit -m "feat: first-login must-change-password gate + set-password screen (Path A, security-sensitive)"

Task 10 — [Path A · UI] Admin "Create adult account" affordance + temp-password sheet

In the members management surface (household/page.dart:206-216 add-member button, role-gated by AdminGuard), add an admin-only "Create adult account" affordance offering coParent/otherAdult (NEVER child). It drives a new bloc handler that calls HouseholdRepository.adminCreateAdult and, on success, surfaces the temp password ONCE via a sheet modeled on co_parent_code_sheet (copy/done). The child add path stays exactly where it is.

Files

  • app/lib/inside/blocs/household/members_bloc.dart (new event + handler + transient state fields)
  • app/lib/inside/routes/authenticated/members/create_adult_sheet.dart (new — form + temp-password result; lives under members/ beside co_parent_code_sheet.dart/account_invite_sheet.dart)
  • app/lib/inside/routes/authenticated/household/page.dart (add the affordance beside the add-member button at L206-216)
  • app/lib/inside/i18n/strings.dart (strings)
  • app/test/unit/blocs/members_bloc_admin_create_adult_test.dart (new)

Interfaces

  • Consumed: HouseholdRepository.adminCreateAdult({required actingMemberId, required displayName, required MemberKind kind, String? email}) → Future<({HouseholdMember member, String tempPassword})>.
  • Produced: MembersState.adminCreateInFlight: bool, MembersState.adminCreateTempPassword: String?, MembersState.adminCreateAttempt: int, MembersState.adminCreateError: String? (all transient, @JsonKey(includeToJson:false, includeFromJson:false), mirroring the account-invite fields).

Steps

  • RED — members_bloc_admin_create_adult_test.dart: dispatching AdminCreateAdultRequested(displayName:'X', kind: coParent) emits inFlight then a state carrying adminCreateTempPassword (from a mock repo returning (member, 'temp-xyz')); a child kind never reaches the repo (guarded in the sheet, but assert the handler rejects it defensively → error state). Run → EXPECT FAIL.

  • GREEN — add the transient fields to MembersState (mirror the accountInvite* cluster: constructor defaults, declarations with @JsonKey(includeToJson:false, includeFromJson:false), copyWith, props) and the event + handler to MembersBloc:

    Future<void> _onAdminCreateAdultRequested(
    AdminCreateAdultRequested event,
    Emitter<MembersState> emit,
    ) async {
    if (event.kind == MemberKind.child) {
    // Defensive: the UI never offers child here, but never let this handler
    // become a child-creation backdoor (isAdult firewall, in-app layer too).
    emit(state.copyWith(
    setAdminCreateError: () => Strings.adminCreateAdultOnly));
    return;
    }
    emit(state.copyWith(
    adminCreateInFlight: true, setAdminCreateError: () => null));
    try {
    final res = await _householdRepository.adminCreateAdult(
    actingMemberId: state.currentMemberId ?? '',
    displayName: event.displayName.trim(),
    kind: event.kind,
    email: event.email?.trim().isEmpty ?? true ? null : event.email!.trim(),
    );
    emit(state.copyWith(
    adminCreateInFlight: false,
    adminCreateAttempt: ++_adminCreateAttempt,
    setAdminCreateTempPassword: () => res.tempPassword,
    setAdminCreateError: () => null,
    ));
    } on AdminCreateAdultException catch (e) {
    emit(state.copyWith(
    adminCreateInFlight: false,
    setAdminCreateError: () => e.message));
    } on Exception catch (e) {
    emit(state.copyWith(
    adminCreateInFlight: false,
    setAdminCreateError: () => e.toString()));
    }
    }

    Register the handler (on<AdminCreateAdultRequested>(_onAdminCreateAdultRequested);) and add the _adminCreateAttempt counter field (mirror _coParentInviteAttempt). Add the AdminCreateAdultRequested event class (mirror MemberEditorSaved's style with displayName, kind, email?). Run the RED test → EXPECT PASS.

  • GREEN — app/lib/inside/routes/authenticated/members/create_adult_sheet.dart: a modal (via showDsSheet, matching join_with_code_sheet.dart/co_parent_code_sheet.dart) exposing showCreateAdultSheet(BuildContext context) — capture the MembersBloc before the sheet opens (BlocProvider.value), like showCoParentCodeSheet does. Body: a name field, a kind segment offering ONLY coParent/otherAdult, an optional email field, and a submit that dispatches AdminCreateAdultRequested. A BlocConsumer<MembersBloc, MembersState> baseline-compares adminCreateAttempt (mirroring member_editor_sheet.dart's _baselineCoParentAttempt pattern) and, on a new adminCreateTempPassword, swaps to a temp-password result view (copy button + "Done") — modeled on co_parent_code_sheet.dart _CoParentCodeBody (the InviteCodeCard copy/done pattern). Show adminCreateError inline.

  • GREEN — in household/page.dart near the add-member button (L206-216), add an admin-only "Create adult account" affordance (visibility gated the same way the surface is — the route is behind AdminGuard; add a canAccessAdmin/capability check if the button is reachable to non-admins) that calls showCreateAdultSheet(context). Add the strings (adminCreateAdultTitle, adminCreateAdultOnly, adminCreateAdultTempPasswordLabel, etc.).

  • Run: fvm flutter test app/test/unit/blocs/members_bloc_admin_create_adult_test.dart → EXPECT PASS.

  • Run: fvm dart format on the touched files + fvm flutter analyze app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household → EXPECT no issues.

  • Commit:

    git add app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/create_adult_sheet.dart app/lib/inside/routes/authenticated/household/page.dart app/lib/inside/i18n/strings.dart app/test/unit/blocs/members_bloc_admin_create_adult_test.dart
    git commit -m "feat: admin 'create adult account' UI + temp-password-once sheet (Path A)"

Task 11 — [Path A · SECURITY-SENSITIVE] Live smoke probes on the Edge Function

Append house-style live smoke probes to admin-create-adult/index.ts (the controller runs them after deploy). Prove: an admin CAN create an adult; a NON-admin CANNOT; a CHILD kind CANNOT be created; the created member lands active/{member} with must_change_password metadata; the must-change gate blocks the app until rotated (documented as an app-side probe); and a created invite_events row is logged. Mirror the two-identity + request.jwt.claims self-skipping technique used by the child-freeze/companion probes and the accept_invite probe fixture.

ROUTE THROUGH security-reviewer BEFORE COMMIT (the probes are the executable proof of the DUAL GATE).

Files

  • infra/supabase/functions/admin-create-adult/index.ts (append the probe comment block)

Steps

  • Append to admin-create-adult/index.ts:

    // ── LIVE SMOKE PROBES (controller runs after deploy — house style) ───────────
    // Fixture: household H with an ACTIVE parent+admin A (auth-linked) and a SECOND
    // active member B who is NOT an admin (roles={member}, no owner). A THIRD
    // household H2 with its own admin C. Run each probe as the named identity (the
    // FunctionsClient attaches that identity's JWT). Clean up minted auth users +
    // member rows + invite_events afterward (service-role).
    //
    // 1. ADMIN OK: as A, POST {household_id:H, display_name:'Co', kind:'co_parent'}
    // -> EXPECT {ok:true, member_id, auth_user_id, temp_password:<non-empty>}.
    // Then: household_members has a row (H, kind='co_parent', status='active',
    // roles={member}, auth_user_id set); the auth user has
    // user_metadata.must_change_password === true; one invite_events row
    // kind='created', actor_auth_user_id=A.
    // 2. NON-ADMIN denied: as B (active but not admin/owner of H), POST the same
    // -> EXPECT {ok:false, reason:'not_authorized'} (403) and NO auth user /
    // member row created.
    // 3. CROSS-HOUSEHOLD denied: as C (admin of H2, NOT of H), POST {household_id:H,
    // …} -> EXPECT {ok:false, reason:'not_authorized'} (403) — the caller-authz
    // binds the admin to the TARGET household.
    // 4. CHILD FIREWALL: as A, POST {household_id:H, display_name:'Kid',
    // kind:'child'} -> EXPECT {ok:false, reason:'not_adult'} and NO auth user /
    // member row (the isAdult firewall; children keep child-auth+link_child).
    // 5. UNAUTH: with no JWT (verify_jwt should already 401 the call). If reached,
    // getUser returns no user -> EXPECT {ok:false, reason:'not_authorized'} (401).
    // 6. INVALID INPUT: as A, POST {household_id:H, display_name:'', kind:'co_parent'}
    // -> EXPECT {ok:false, reason:'invalid_input'} (400).
    // 7. ROLLBACK (manual/observational): temporarily point member insert at a bad
    // column to force memberErr -> EXPECT the just-minted auth user is DELETED
    // (no orphan). Restore after. (Skip in routine runs; documented for audit.)
    // 8. MUST-CHANGE GATE (app-side probe, not this function): sign in as the
    // probe-1 adult with its temp_password -> EXPECT the app routes to the
    // set-password screen and blocks the shell until updatePassword succeeds;
    // after rotation, user_metadata.must_change_password === false and the app
    // lands on the authenticated shell.
    // 9. advisors sweep: security + performance advisors show no NEW findings.
    // 10. cleanup (service-role): delete minted auth users + member rows +
    // invite_events 'created' probe rows.
  • Route through security-reviewer. Address CRITICAL/HIGH.

  • Commit:

    git add infra/supabase/functions/admin-create-adult/index.ts
    git commit -m "test: admin-create-adult live smoke probes (dual-gate proof, Path A)"

Task 12 — [docs] Update the auth architecture page + gap ledger

Reflect the new admin-direct-create path and the must-change gate in the hand-curated architecture docs (the auth page is the auth gap ledger per CLAUDE.md).

Files

  • app/test-gallery/authored/developer/architecture/ auth page (the file documenting auth — locate the auth-skeleton page)
  • docs/decisions/2026-06-22-northstar-poc-domain-gaps.md (if the admin-create path closes/adds a listed gap)

Steps

  • Add a short section to the auth architecture page: the admin-create-adult Edge Function (service-role, caller-authz, isAdult firewall, temp-password + must-change metadata), the must-change route guard, and the updatePassword/mustChangePassword auth-seam additions. Mark anything not yet deployed as dashed/planned per the doc convention.
  • Note in the ledger that Path B G5 (join-with-code accept surface) is CONFIRMED built + wired + tested (closing the deep-dive G5 item), and that admin-direct-create is the new adult-onboarding path.
  • Run: no test; fvm dart format not applicable to markdown.
  • Commit:
    git add app/test-gallery/authored/developer/architecture/<auth-page>.md docs/decisions/2026-06-22-northstar-poc-domain-gaps.md
    git commit -m "docs: admin-direct-create + must-change gate in auth architecture + gap ledger"

Task 13 — [wrap] Full-suite baseline verification + graphify update

Steps

  • Run the full app suite: fvm flutter test (in app/) → EXPECT test count ≥ 704 (baseline must not DROP; new tests raise it) and All tests passed!.
  • Run the SDK suite: fvm dart test (in packages/client_sdk/) → EXPECT ≥ 1128 and all passing.
  • Run the design-system suite: fvm flutter test (in packages/design_system/) → EXPECT ≥ 287 and all passing (should be untouched).
  • Run fvm dart format --set-exit-if-changed . at the workspace root and fvm flutter analyze → EXPECT clean.
  • Run graphify update . at the repo root → EXPECT the AST graph refreshed (no API cost).
  • Verify staging hygiene: git status shows NO graphify-out/, .superpowers/, or .claude/ staged; the committed graph.json/GRAPH_REPORT.md deltas from graphify update are the only graphify changes to stage.
  • Commit the graph refresh:
    git add graphify-out/graph.json graphify-out/GRAPH_REPORT.md
    git commit -m "chore: graphify update after add-an-adult-to-household"

Self-review — spec coverage matrix

Design-doc elementTask(s)
LOCKED D1 (temp password shown once)3 (genTempPassword + return), 4 (tempPassword through port), 10 (shown-once sheet)
LOCKED D2 (force first-login rotation)3 (must_change metadata), 8 (surface flag + updatePassword), 9 (gate + set-password screen)
LOCKED D3 (BOTH paths) — Path A3–11
LOCKED D3 — Path B feedback fix1
LOCKED D3 — Path B G5 accept surfaceDeviation D-1 (already built + wired + tested; Task 1 adds a regression guard)
LOCKED D4 (audit created)2 (migration + enum), 3 (audit insert), 5/7 (local twin logs it)
isAdult firewall (no child either path)3 (Edge Fn), 5 (service), 7 (tests), 10 (UI + defensive handler)
Adults → active/{member}, no pendingConsent3, 5
DUAL GATE (service authz AND RLS)3 (caller-authz + service-role write bypasses RLS by design), 11 (probes 2/3 prove it)
SECURITY-SENSITIVE routing3, 7, 9, 11 (security-reviewer)
PKCE-on-boot assumed builtTask 1/9 rely on it (runner.dart:133 already calls completeAuthFromUrl) — assumed per constraint
Suite baselines / graphify update / staging hygiene13

Placeholder scan: no "TBD"/"similar to Task N" — every code block is repeated in full. Type consistency: AdminCreateAdultException/AdminCreateAdultRejectionReason (Task 5) are consumed identically in the port mapper (Task 4), service (Task 5), tests (Task 7), and UI handler (Task 10); the ({String memberId, String authUserId, String tempPassword}) record shape is identical across port (Task 4), and the ({HouseholdMember member, String tempPassword}) shape is identical across service/facade/repo (Tasks 5/6) and the bloc handler (Task 10); mustChangePassword flows AuthAccountAuthUser consistently (Task 8) into the guard (Task 9).