Skip to main content

SP‑B — Code-First Onboarding + Auto-Detect Invite Redemption + Share-Code↔Member Binding — 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: Make the invite code the first step of onboarding, redeem any code (adult or child) through one auto-detecting path (fixing the live child-code bug), let an admin optionally bind a generated code to an existing member, and make the current active code legible so users stop pasting rotated/stale codes.

Architecture: Two new SECURITY DEFINER Postgres RPCs — peek_invite (read-only detect) and redeem_invite (routing wrapper) — sit in front of the EXISTING accept_invite/link_child internals; detection is deterministic because adult tokens hash into household_members.invite_token_hash and household child codes hash into households.child_join_code_hash. The SDK exposes peekInvite/redeemInvite through the one data path (facade → HouseholdService → cloud Households mixin → db.rpc), modeled in FakePostgrest for tests. The app re-routes the existing join sheet + adds a first-run onboarding code step + a switcher "Join a household" entry (KEEP BOTH surfaces), plus a generate-side member-binding picker and current-code legibility.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter, fvm dart); pub workspace; bloc/cubit; auto_route; Supabase (PostgREST + SECURITY DEFINER RPCs); the flow-test harness (flowTest + MocksContainer + testAppBuilder).

Global Constraints

  • One data path: Bloc/Cubit → Repository → Client facade → HouseholdService → Adapter. Domain rules live in the SDK service; repositories are thin presentation delegates. Presentation NEVER imports drift/supabase/any I/O — only the client_sdk facade.
  • Typed error handling ONLY: on <SpecificException> — NEVER bare catch (e), NEVER catch Error or its subtypes. Reuse InviteAcceptException(InviteRejectionReason) and ChildLinkException(ChildLinkRejectionReason) from packages/client_sdk/lib/src/models/exceptions.dart.
  • Invariants in service AND schema: the redemption RPCs are SECURITY DEFINER, validate server-side, keep codes hashed (encode(extensions.digest(code,'sha256'),'hex') — byte-identical to Dart hashInviteToken), and are revoke execute … from public, anon + grant execute … to authenticated. RLS unchanged (redemption is via RPC, not direct table writes).
  • Migrations are file-only under infra/supabase/migrations/; applying peek_invite/redeem_invite to prod bgedvvmihygwxhjxlvfu is DEPLOY-GATED (owner-authorized only). All SDK/app tests model the RPCs in FakePostgrest — they never touch prod.
  • No brand strings in package/class/RPC/file names; all user-facing copy via app/lib/inside/i18n/strings.dart (abstract final class Strings, plain const — follow <scope><Action> key naming).
  • COPPA: the child path is parent-initiated attach only (link_child); do NOT add a child self-signup entry (that re-raises the deferred VPC gate).
  • KEEP BOTH join surfaces (owner 2026-07-23): the switcher "Join a household" entry AND the re-routed More-tab sheet, both on the one peek_invite/redeem_invite path.
  • Bind-to-member is OPTIONAL (owner 2026-07-23): an unbound generated code creates a fresh member on redemption; binding is never required.
  • Flow-test structure: flow tests are scoped to an EPIC + FEATURE SET; ONE flowTest carries MULTIPLE STORIES via FTDescription (epic→story→AC). Do NOT write one flowTest per story.
  • Baselines must not drop: SDK ~1191 green, app ~806 green at branch head. New tests add to these.
  • FVM only (fvm dart test in a package dir; fvm flutter test for the app). Run graphify update . after code changes.

Reference anchors (verified in the codebase — implementers may re-read these)

Existing RPCs (do NOT modify — wrap them):

  • accept_invite(p_token text)infra/supabase/migrations/20260711000400_accept_invite_rpc.sql. Hashes p_token, looks up household_members.invite_token_hash, email-bound, returns jsonb {ok:true, household_id} or {ok:false, reason} where reason ∈ invalid|expired|already_linked|email_mismatch|already_member.
  • link_child(p_code text, p_display_name text) — canonical body in infra/supabase/migrations/20260712000500_child_link_hardening.sql. Hashes p_code; ATTACH path via household_members.invite_token_hash on a kind='child' row; NEW path via households.child_join_code_hash. Returns {ok:true, household_id, member_id, path} or {ok:false, reason} where reason ∈ not_a_child|expired|already_linked|invalid_code.
  • SECURITY DEFINER convention: language plpgsql security definer set search_path = 'public'; extensions.digest(...) fully qualified; revoke execute … from public, anon; grant execute … to authenticated;; end file with a -- LIVE SMOKE PROBES comment block.

Detection columns: an adult invite token AND a per-child attach code both live in household_members.invite_token_hash (distinguished by the row's kind); a household-wide child code lives in households.child_join_code_hash. A single hash lookup across these routes unambiguously.

SDK seams:

  • PostgrestPort (packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart): Future<Map<String,dynamic>> rpc(String fn, Map<String,dynamic> params).
  • Cloud Households mixin (packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart): existing acceptInviteRemote calls db.rpc('accept_invite', {'p_token': token}); reason→exception switches _inviteRejection/_childLinkRejection at ~lines 254/270.
  • StoragePort (packages/client_sdk/lib/src/adapters/adapter.dart): cloud-only verbs are named xxxRemote and throw UnimplementedError in InMemoryStorageAdapter + FakePort.
  • HouseholdService (packages/client_sdk/lib/src/services/household_service.dart): acceptInvite at ~line 643 with a _useRemoteInviteAccept flag; SP-A setActiveHousehold (~131), bootstrapSession (~98), listMyHouseholds (~123).
  • Client/ClientImpl (packages/client_sdk/lib/src/client/client.dart + client_impl.dart): abstract methods + thin delegates.
  • FakePostgrest (packages/client_sdk/test/cloud/fake_postgrest.dart): rpc() returns the single rpcResponse field (set one canned response per call). Cloud routing tests seed a full household_members row then set fake.rpcResponse.
  • Exceptions (packages/client_sdk/lib/src/models/exceptions.dart): InviteAcceptException(InviteRejectionReason) reasons invalid|expired|emailMismatch|alreadyMember|alreadyLinked; ChildLinkException(ChildLinkRejectionReason).

App seams:

  • Join sheet: app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart (showJoinWithCodeSheet, _JoinWithCodeBody) + app/lib/inside/blocs/join_household/{cubit,state}.dart (JoinHouseholdCubit.submit(rawCode) calls acceptInvite; states JoinIdle{error}/JoinSubmitting/JoinSuccess(householdId)/JoinFailure(message)).
  • Switcher: app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart (showHouseholdSwitcherSheet, HouseholdSwitcherSheet with onSelect/onCreateAnother; createAnother DsRow keyed HouseholdSwitcher.createAnother).
  • Setup: app/lib/inside/routes/authenticated/setup/page.dart (SetupPage{isCreatingAdditional}, SetupStep enum, _NameCta{showInviteCode,onHaveInviteCode}, _InviteCodeDialog keys SetupPage.inviteCodeField/Submit/Error) + SetupBloc handling SetupInviteCodeSubmitted.
  • Repo: app/lib/outside/repositories/household/household_repository.dart (acceptInvite, signUpChild, issueChildAttachCode, issueHouseholdChildJoinCode, inviteMember, SP-A switchActiveHousehold/setActiveHousehold/bootstrapSession/getHousehold).
  • Generate-side sheets: household_child_join_code_sheet.dart (household code, HouseholdSettingsBloc), members/child_signup_code_sheet.dart (per-child, MembersBloc), members/account_invite_sheet.dart (adult), shared members/invite_code_card.dart.
  • Guard/first-run: app/lib/inside/routes/guards/authenticated_guard.dart (getHousehold()==nullSetupRoute()), wired in app/lib/app/builder.dart.
  • Strings: app/lib/inside/i18n/strings.dart (setupInviteCode*, joinWithCode*, householdSwitcher*, householdSettingsChildJoin*).
  • Flow-test template: app/test/flows/household_switch_test.dart; setup errors app/test/flows/setup_invite_error_test.dart; MocksContainer in app/test/util/mocks/mocked_app.dart; warps in app/test/util/warps/.

Task 1: Migration — peek_invite + redeem_invite routing RPCs (file-only, DEPLOY-GATED)

Files:

  • Create: infra/supabase/migrations/20260723000100_peek_redeem_invite_rpc.sql

Interfaces:

  • Produces (wire contract the SDK relies on):

    • peek_invite(p_code text) returns jsonb{ok:true, type:'adult'|'child', household_id:uuid, household_name:text, member_name:text|null, requires_display_name:bool} on a resolvable code; {ok:false, reason:'not_found'|'expired'|'already_member'|'email_mismatch'} otherwise. Read-only — no writes.
    • redeem_invite(p_code text, p_display_name text default null) returns jsonb{ok:true, household_id:uuid} on success; {ok:false, reason:text} on failure (reason vocabulary is the union of accept_invite/link_child reasons plus not_found).
  • Step 1: Write the migration SQL

Create infra/supabase/migrations/20260723000100_peek_redeem_invite_rpc.sql:

-- SP-B: peek_invite + redeem_invite — auto-detecting routing wrappers over the
-- existing accept_invite / link_child internals. Detection is deterministic:
-- an adult invite token and a per-child attach code hash into
-- household_members.invite_token_hash (distinguished by the row's kind); a
-- household-wide child code hashes into households.child_join_code_hash.
-- peek_invite is READ-ONLY (no side effects). redeem_invite delegates to the
-- existing functions and normalizes their return to {ok, household_id, reason}.

create extension if not exists pgcrypto with schema extensions;

-- ---------------------------------------------------------------------------
-- peek_invite: detect + preview, no side effects.
-- ---------------------------------------------------------------------------
create or replace function public.peek_invite(p_code text)
returns jsonb
language plpgsql security definer set search_path = 'public' as $$
declare
v_uid uuid := auth.uid();
v_email text := lower(coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email', ''));
v_hash text;
mm record;
hh record;
begin
if v_uid is null then
return jsonb_build_object('ok', false, 'reason', 'not_found');
end if;

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

-- 1. household_members.invite_token_hash — adult invite OR per-child attach.
select id, household_id, kind, auth_user_id, status, expires_at, email, display_name
into mm
from public.household_members
where invite_token_hash = v_hash
limit 1;

if found then
if mm.expires_at is not null and mm.expires_at <= now() then
return jsonb_build_object('ok', false, 'reason', 'expired');
end if;
-- already a member of that household?
if exists (
select 1 from public.household_members x
where x.household_id = mm.household_id and x.auth_user_id = v_uid
) then
return jsonb_build_object(
'ok', false, 'reason', 'already_member',
'household_id', mm.household_id,
'household_name', (select name from public.households where id = mm.household_id)
);
end if;

if mm.kind = 'child' then
-- child-attach: joiner supplies (or confirms) the display name.
return jsonb_build_object(
'ok', true, 'type', 'child',
'household_id', mm.household_id,
'household_name', (select name from public.households where id = mm.household_id),
'member_name', mm.display_name,
'requires_display_name', true
);
else
-- adult invite: email-bound. Surface a mismatch hint before redeeming.
if v_email = '' or v_email <> lower(coalesce(mm.email, '')) then
return jsonb_build_object('ok', false, 'reason', 'email_mismatch');
end if;
return jsonb_build_object(
'ok', true, 'type', 'adult',
'household_id', mm.household_id,
'household_name', (select name from public.households where id = mm.household_id),
'member_name', mm.display_name,
'requires_display_name', false
);
end if;
end if;

-- 2. households.child_join_code_hash — household-wide child code (new member).
select id, name into hh
from public.households
where child_join_code_hash = v_hash
limit 1;

if found then
if exists (
select 1 from public.household_members x
where x.household_id = hh.id and x.auth_user_id = v_uid
) then
return jsonb_build_object(
'ok', false, 'reason', 'already_member',
'household_id', hh.id, 'household_name', hh.name
);
end if;
return jsonb_build_object(
'ok', true, 'type', 'child',
'household_id', hh.id, 'household_name', hh.name,
'member_name', null, 'requires_display_name', true
);
end if;

return jsonb_build_object('ok', false, 'reason', 'not_found');
end;
$$;

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

-- ---------------------------------------------------------------------------
-- redeem_invite: detect type, delegate to the existing internal function,
-- normalize its return. accept_invite / link_child each re-hash + re-auth, so
-- this is pure routing — not a re-implementation.
-- ---------------------------------------------------------------------------
create or replace function public.redeem_invite(p_code text, p_display_name text default null)
returns jsonb
language plpgsql security definer set search_path = 'public' as $$
declare
v_uid uuid := auth.uid();
v_hash text;
v_res jsonb;
is_adult boolean;
begin
if v_uid is null then
return jsonb_build_object('ok', false, 'reason', 'not_found');
end if;

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

-- Determine which internal to call. An adult token is a non-child
-- household_members.invite_token_hash row; everything else (child-attach row
-- OR household child_join_code_hash) routes to link_child.
select (kind <> 'child') into is_adult
from public.household_members
where invite_token_hash = v_hash
limit 1;

if is_adult is true then
v_res := public.accept_invite(p_code);
else
v_res := public.link_child(p_code, p_display_name);
end if;

-- Normalize to {ok, household_id, reason?}. Both internals already return
-- household_id on success and reason on failure; link_child adds member_id +
-- path which we drop here. If neither matched, is_adult is null and
-- link_child returns {ok:false, reason:'invalid_code'} — map to not_found.
if (v_res ->> 'ok')::boolean is true then
return jsonb_build_object('ok', true, 'household_id', v_res -> 'household_id');
end if;

return jsonb_build_object(
'ok', false,
'reason', coalesce(
case when v_res ->> 'reason' = 'invalid_code' then 'not_found' else v_res ->> 'reason' end,
'not_found'
)
);
end;
$$;

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

-- LIVE SMOKE PROBES (run post-apply as an authenticated user; DEPLOY-GATED):
-- 1. select public.peek_invite('does-not-exist'); -- {ok:false, reason:not_found}
-- 2. (with a live adult invite token for a DIFFERENT email) -- peek -> {ok:false, reason:email_mismatch}
-- 3. (with a live household child_join code) -- peek -> {ok:true, type:child, requires_display_name:true}
-- 4. select public.redeem_invite('<child code>', 'Robin'); -- {ok:true, household_id:...}; re-run -> {ok:false, reason:already_member/invalid}
-- 5. Confirm peek_invite performed NO writes (row counts unchanged after steps 1-3).
  • Step 2: Review the SQL against the wrapped internals

Re-read 20260711000400_accept_invite_rpc.sql and 20260712000500_child_link_hardening.sql. Verify: (a) the hash expression is byte-identical; (b) peek_invite reads only (no INSERT/UPDATE); (c) grants match the convention (revoke … from public, anon; grant … to authenticated); (d) redeem_invite delegates rather than re-implements. There is no automated unit test at the SQL layer (consistent with SP-A Task 1 and the other RPC migrations — the behavioral contract is tested in the SDK via FakePostgrest). Do NOT apply to prod.

  • Step 3: Commit
git add infra/supabase/migrations/20260723000100_peek_redeem_invite_rpc.sql
git commit -m "feat(sp-b): peek_invite + redeem_invite routing RPCs (file-only, deploy-gated)"

Task 2: SDK cloud path — InvitePreview model + peekInviteRemote/redeemInviteRemote (StoragePort + cloud mixin + local stubs + fake modeling)

Files:

  • Create: packages/client_sdk/lib/src/models/invite_preview.dart
  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (add two abstract verbs)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (implement via db.rpc)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart (UnimplementedError stubs)
  • Modify: packages/client_sdk/test/support/fake_port.dart (UnimplementedError stubs)
  • Modify: packages/client_sdk/lib/client_sdk.dart (export invite_preview.dart)
  • Test: packages/client_sdk/test/cloud/peek_redeem_invite_routing_test.dart

Interfaces:

  • Produces:

    • enum InviteType { adult, child }
    • class InvitePreview { final InviteType type; final String householdId; final String householdName; final String? memberName; final bool requiresDisplayName; }
    • StoragePort.peekInviteRemote({required String code}) → Future<InvitePreview> (cloud-only; throws typed exception on failure)
    • StoragePort.redeemInviteRemote({required String code, String? displayName}) → Future<String> (returns household_id; cloud-only)
  • Step 1: Write the InvitePreview model

Create packages/client_sdk/lib/src/models/invite_preview.dart:

/// Detected invite type from `peek_invite` (SP-B).
enum InviteType { adult, child }

/// Read-only preview of an invite/join code, returned by `peek_invite`.
/// Carries what the UI needs to confirm the join BEFORE committing: which
/// household, which member (if the code binds to one), and whether a display
/// name must be collected (child codes) before `redeemInvite`.
class InvitePreview {
const InvitePreview({
required this.type,
required this.householdId,
required this.householdName,
required this.requiresDisplayName,
this.memberName,
});

final InviteType type;
final String householdId;
final String householdName;
final bool requiresDisplayName;
final String? memberName;

factory InvitePreview.fromRpc(Map<String, dynamic> row) => InvitePreview(
type: (row['type'] as String) == 'child' ? InviteType.child : InviteType.adult,
householdId: row['household_id'] as String,
householdName: row['household_name'] as String,
requiresDisplayName: (row['requires_display_name'] as bool?) ?? false,
memberName: row['member_name'] as String?,
);
}
  • Step 2: Add the two abstract verbs to StoragePort

In packages/client_sdk/lib/src/adapters/adapter.dart, beside the existing acceptInviteRemote/linkChildRemote (~line 105), add (and add import '../models/invite_preview.dart'; at the top if not present):

/// Read-only detect+preview of an invite/join code (SP-B `peek_invite`).
/// Cloud-only — local adapters throw [UnimplementedError]. Throws
/// [InviteAcceptException] on a resolvable failure (not_found→invalid,
/// expired, already_member, email_mismatch).
Future<InvitePreview> peekInviteRemote({required String code});

/// Redeem an invite/join code (SP-B `redeem_invite`); routes adult→accept,
/// child→attach. Returns the joined `household_id`. Cloud-only.
Future<String> redeemInviteRemote({required String code, String? displayName});
  • Step 3: Write the failing cloud-routing test

Create packages/client_sdk/test/cloud/peek_redeem_invite_routing_test.dart (model on accept_invite_routing_test.dart). One canned fake.rpcResponse per call:

import 'package:client_sdk/src/adapters/cloud/supabase_storage_adapter.dart';
import 'package:client_sdk/src/models/exceptions.dart';
import 'package:client_sdk/src/models/invite_preview.dart';
import 'package:test/test.dart';

import 'fake_postgrest.dart';

void main() {
late FakePostgrest fake;
late SupabaseStorageAdapter adapter;

setUp(() {
fake = FakePostgrest();
adapter = SupabaseStorageAdapter(fake);
});

group('peekInviteRemote', () {
test('adult code -> InvitePreview(adult, no display name)', () async {
fake.rpcResponse = {
'ok': true, 'type': 'adult', 'household_id': 'h1',
'household_name': 'Smith', 'member_name': 'Jo', 'requires_display_name': false,
};
final p = await adapter.peekInviteRemote(code: 'tok');
expect(p.type, InviteType.adult);
expect(p.householdName, 'Smith');
expect(p.requiresDisplayName, isFalse);
expect(fake.lastRpcFn, 'peek_invite');
});

test('child code -> InvitePreview(child, requiresDisplayName)', () async {
fake.rpcResponse = {
'ok': true, 'type': 'child', 'household_id': 'h1',
'household_name': 'Smith', 'member_name': null, 'requires_display_name': true,
};
final p = await adapter.peekInviteRemote(code: 'code');
expect(p.type, InviteType.child);
expect(p.requiresDisplayName, isTrue);
});

test('not_found -> InviteAcceptException(invalid)', () async {
fake.rpcResponse = {'ok': false, 'reason': 'not_found'};
expect(
() => adapter.peekInviteRemote(code: 'x'),
throwsA(isA<InviteAcceptException>()
.having((e) => e.reason, 'reason', InviteRejectionReason.invalid)),
);
});

test('email_mismatch -> InviteAcceptException(emailMismatch)', () async {
fake.rpcResponse = {'ok': false, 'reason': 'email_mismatch'};
expect(
() => adapter.peekInviteRemote(code: 'x'),
throwsA(isA<InviteAcceptException>()
.having((e) => e.reason, 'reason', InviteRejectionReason.emailMismatch)),
);
});

test('already_member -> InviteAcceptException(alreadyMember)', () async {
fake.rpcResponse = {'ok': false, 'reason': 'already_member', 'household_id': 'h1', 'household_name': 'Smith'};
expect(
() => adapter.peekInviteRemote(code: 'x'),
throwsA(isA<InviteAcceptException>()
.having((e) => e.reason, 'reason', InviteRejectionReason.alreadyMember)),
);
});
});

group('redeemInviteRemote', () {
test('success -> returns household_id', () async {
fake.rpcResponse = {'ok': true, 'household_id': 'h1'};
expect(await adapter.redeemInviteRemote(code: 'c', displayName: 'Robin'), 'h1');
expect(fake.lastRpcFn, 'redeem_invite');
expect(fake.lastRpcParams, {'p_code': 'c', 'p_display_name': 'Robin'});
});

test('failure reason -> typed exception', () async {
fake.rpcResponse = {'ok': false, 'reason': 'expired'};
expect(
() => adapter.redeemInviteRemote(code: 'c'),
throwsA(isA<InviteAcceptException>()
.having((e) => e.reason, 'reason', InviteRejectionReason.expired)),
);
});
});
}

Note: this test references fake.lastRpcFn / fake.lastRpcParams. FakePostgrest.rpc currently only stores rpcResponse; add capture fields in the same step (Step 4).

  • Step 4: Add lastRpcFn/lastRpcParams capture to FakePostgrest

In packages/client_sdk/test/cloud/fake_postgrest.dart, extend the existing rpc method to record the call (keep the single rpcResponse return — do NOT add a per-function map; peek and redeem are tested one call per test):

String? lastRpcFn;
Map<String, dynamic>? lastRpcParams;

@override
Future<Map<String, dynamic>> rpc(String fn, Map<String, dynamic> params) async {
lastRpcFn = fn;
lastRpcParams = params;
return rpcResponse ?? const {'ok': true};
}
  • Step 5: Run the test — verify it FAILS

Run: cd packages/client_sdk && fvm dart test test/cloud/peek_redeem_invite_routing_test.dart Expected: FAIL — peekInviteRemote/redeemInviteRemote not defined on the adapter.

  • Step 6: Implement the cloud mixin methods

In packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart, beside acceptInviteRemote/linkChildRemote, add:

@override
Future<InvitePreview> peekInviteRemote({required String code}) async {
final res = await db.rpc('peek_invite', {'p_code': code});
if (res['ok'] == true) return InvitePreview.fromRpc(res);
throw _peekRejection(res['reason'] as String?);
}

@override
Future<String> redeemInviteRemote({required String code, String? displayName}) async {
final res = await db.rpc('redeem_invite', {'p_code': code, 'p_display_name': displayName});
if (res['ok'] == true) return res['household_id'] as String;
throw _peekRejection(res['reason'] as String?);
}

/// Maps peek/redeem wire reasons onto the existing typed invite exception.
InviteAcceptException _peekRejection(String? reason) => InviteAcceptException(
switch (reason) {
'expired' => InviteRejectionReason.expired,
'already_member' => InviteRejectionReason.alreadyMember,
'already_linked' => InviteRejectionReason.alreadyLinked,
'email_mismatch' => InviteRejectionReason.emailMismatch,
'not_a_child' => InviteRejectionReason.invalid,
_ => InviteRejectionReason.invalid, // not_found / invalid_code / null
},
);

Add import '../../models/invite_preview.dart'; to the mixin file if not already imported.

  • Step 7: Add local stubs (in-memory + FakePort)

In in_memory_storage_adapter.dart and test/support/fake_port.dart, beside the existing acceptInviteRemote stubs:

@override
Future<InvitePreview> peekInviteRemote({required String code}) =>
throw UnimplementedError('peekInviteRemote is cloud-only');

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

Add the invite_preview.dart import to both. Export the model from the barrel packages/client_sdk/lib/client_sdk.dart.

  • Step 8: Run tests — verify PASS + no regressions

Run: cd packages/client_sdk && fvm dart test test/cloud/peek_redeem_invite_routing_test.dart → PASS. Run: cd packages/client_sdk && fvm dart test → full suite green (baseline + new).

  • Step 9: Commit
git add packages/client_sdk/lib/src/models/invite_preview.dart packages/client_sdk/lib/src/adapters packages/client_sdk/lib/client_sdk.dart packages/client_sdk/test/cloud/peek_redeem_invite_routing_test.dart packages/client_sdk/test/support/fake_port.dart
git commit -m "feat(sp-b): SDK peek/redeem cloud verbs + InvitePreview model"

Task 3: SDK service + facade — HouseholdService.peekInvite/redeemInvite + Client/ClientImpl

Files:

  • Modify: packages/client_sdk/lib/src/services/household_service.dart
  • Modify: packages/client_sdk/lib/src/client/client.dart
  • Modify: packages/client_sdk/lib/src/client/client_impl.dart
  • Test: packages/client_sdk/test/services/peek_redeem_invite_service_test.dart

Interfaces:

  • Consumes: StoragePort.peekInviteRemote/redeemInviteRemote (Task 2).

  • Produces:

    • HouseholdService.peekInvite(String code) → Future<InvitePreview>
    • HouseholdService.redeemInvite({required String code, String? displayName}) → Future<String>
    • Same two on Client (abstract) + ClientImpl (delegate).
  • Step 1: Write the failing service test

Create packages/client_sdk/test/services/peek_redeem_invite_service_test.dart. Use a FakePort subclass that overrides only the two remote verbs (the rest of FakePort is unchanged):

import 'package:client_sdk/src/models/invite_preview.dart';
import 'package:client_sdk/src/services/household_service.dart';
import 'package:test/test.dart';

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

class _PeekPort extends FakePort {
InvitePreview? previewResult;
String? redeemResult;
Object? redeemError;

@override
Future<InvitePreview> peekInviteRemote({required String code}) async => previewResult!;

@override
Future<String> redeemInviteRemote({required String code, String? displayName}) async {
if (redeemError != null) throw redeemError!;
return redeemResult!;
}
}

void main() {
late _PeekPort port;
late HouseholdService service;

setUp(() {
port = _PeekPort();
service = HouseholdService(port); // match the real ctor (see active_household_service_test.dart)
});

test('peekInvite delegates to the port', () async {
port.previewResult = const InvitePreview(
type: InviteType.child, householdId: 'h1', householdName: 'Smith',
requiresDisplayName: true,
);
final p = await service.peekInvite('code');
expect(p.type, InviteType.child);
expect(p.householdName, 'Smith');
});

test('redeemInvite returns the joined household id', () async {
port.redeemResult = 'h1';
expect(await service.redeemInvite(code: 'c', displayName: 'Robin'), 'h1');
});
}

(Adjust the HouseholdService(...) construction to match the real signature — test/services/active_household_service_test.dart shows the exact construction used for SP-A.)

  • Step 2: Run — verify FAIL (fvm dart test test/services/peek_redeem_invite_service_test.dart → methods undefined).

  • Step 3: Implement the service methods

In household_service.dart, beside acceptInvite:

/// Read-only detect+preview of an invite/join code (SP-B). Always remote —
/// there is no local-DB equivalent of cross-table detection worth
/// re-implementing; local mode is not an onboarding target.
Future<InvitePreview> peekInvite(String code) => _storage.peekInviteRemote(code: code);

/// Redeem an invite/join code; routes adult->accept, child->attach. Returns
/// the joined household id. Setting it active + re-bootstrapping is the
/// caller's (repository) concern, mirroring the SP-A switch orchestration.
Future<String> redeemInvite({required String code, String? displayName}) =>
_storage.redeemInviteRemote(code: code, displayName: displayName);

Add import '../models/invite_preview.dart'; if needed.

  • Step 4: Add to Client (abstract) + ClientImpl (delegate)

client.dart (beside acceptInvite, ~line 347):

Future<InvitePreview> peekInvite(String code);
Future<String> redeemInvite({required String code, String? displayName});

client_impl.dart (beside the acceptInvite delegate, ~line 503):

@override
Future<InvitePreview> peekInvite(String code) => _householdService.peekInvite(code);

@override
Future<String> redeemInvite({required String code, String? displayName}) =>
_householdService.redeemInvite(code: code, displayName: displayName);

Add the invite_preview.dart import to both files.

  • Step 5: Run — verify PASS + full SDK suite green.

  • Step 6: Commit

git add packages/client_sdk/lib/src/services/household_service.dart packages/client_sdk/lib/src/client packages/client_sdk/test/services/peek_redeem_invite_service_test.dart
git commit -m "feat(sp-b): HouseholdService.peekInvite/redeemInvite + facade delegation"

Task 4: App repository — peekInvite passthrough + redeemAndActivate orchestration

Files:

  • Modify: app/lib/outside/repositories/household/household_repository.dart
  • Test: app/test/unit/household_repository_redeem_test.dart

Interfaces:

  • Consumes: Client.peekInvite/redeemInvite (Task 3), SP-A setActiveHousehold/bootstrapSession.

  • Produces:

    • HouseholdRepository.peekInvite(String code) → Future<InvitePreview>
    • HouseholdRepository.redeemAndActivate({required String code, required String authUserId, String? displayName}) → Future<Household?> — redeems, sets the joined household active, re-bootstraps, returns the now-active Household.
  • Step 1: Write the failing repo test (app/test/unit/household_repository_redeem_test.dart) — model on app/test/unit/household_repository_active_test.dart. Use a fake Client/SdkClientProvider. Assert redeemAndActivate calls, in order: redeemInvite(code, displayName)setActiveHousehold(authUserId, joinedHouseholdId)bootstrapSession(authUserId), and returns the bootstrapped household. Add a second test that peekInvite is a straight passthrough. Record call order via a log list on the fake client.

  • Step 2: Run — verify FAIL.

  • Step 3: Implement in household_repository.dart (beside switchActiveHousehold):

Future<InvitePreview> peekInvite(String code) =>
_clientProvider.client.peekInvite(code);

/// Redeem [code], set the joined household active, and re-bootstrap the
/// session onto it — the join analogue of SP-A [switchActiveHousehold].
/// Returns the now-active [Household] (or null if bootstrap reports none).
Future<Household?> redeemAndActivate({
required String code,
required String authUserId,
String? displayName,
}) async {
final householdId = await _clientProvider.client
.redeemInvite(code: code, displayName: displayName);
await _clientProvider.client
.setActiveHousehold(authUserId: authUserId, householdId: householdId);
return _clientProvider.client.bootstrapSession(authUserId);
}

InvitePreview is re-exported from the client_sdk barrel (Task 2 Step 7).

  • Step 4: Run — verify PASS + app suite green (cd app && fvm flutter test test/unit/household_repository_redeem_test.dart, then the affected unit dir).

  • Step 5: Commit

git add app/lib/outside/repositories/household/household_repository.dart app/test/unit/household_repository_redeem_test.dart
git commit -m "feat(sp-b): HouseholdRepository peekInvite + redeemAndActivate orchestration"

Task 5: Re-route the More-tab join sheet to auto-detect (peek→redeem + child display-name branch) — the live-bug fix

Files:

  • Modify: app/lib/inside/blocs/join_household/cubit.dart
  • Modify: app/lib/inside/blocs/join_household/state.dart
  • Modify: app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart
  • Modify: app/lib/inside/i18n/strings.dart
  • Test: app/test/unit/blocs/join_household_cubit_test.dart (create if absent)

Interfaces:

  • Consumes: HouseholdRepository.peekInvite/redeemAndActivate (Task 4).
  • Produces: JoinHouseholdCubit.previewAndSubmit(String rawCode) + submitWithName(String displayName) + a new state JoinNeedsDisplayName(InvitePreview preview, {String? suggestedName}).

Design: the cubit first peekInvites. If requiresDisplayName (child) → emit JoinNeedsDisplayName(preview) so the UI collects the name, then submitWithName(name) redeems. If adult → redeem immediately. All failures stay typed (on InviteAcceptException). already_member maps to JoinFailure(Strings.setupInviteCodeAlreadyMember) (the "offer to switch" enhancement is deferred to SP-D — see self-review note).

  • Step 1: Extend the state (join_household/state.dart): add
final class JoinNeedsDisplayName extends JoinHouseholdState {
const JoinNeedsDisplayName(this.preview, {this.suggestedName});
final InvitePreview preview;
final String? suggestedName;
}

(Import InvitePreview from client_sdk.)

  • Step 2: Write failing cubit tests (join_household_cubit_test.dart) with a mock repo + mock auth. Cases:

    • adult code → peekInvite returns adult preview → cubit redeems → JoinSuccess(householdId).
    • child code → peekInvite returns child preview (requiresDisplayName:true) → cubit emits JoinNeedsDisplayName; then submitWithName('Robin')redeemAndActivate(displayName:'Robin')JoinSuccess.
    • expired/invalid/emailMismatch/alreadyMemberpeekInvite throws InviteAcceptException(reason)JoinFailure with the mapped copy (reuse the existing _inviteErrorCopy).
  • Step 3: Run — verify FAIL.

  • Step 4: Implement the cubit — replace submit with previewAndSubmit + submitWithName + private _redeem + a _pendingCode field:

String? _pendingCode;

Future<void> previewAndSubmit(String rawCode) async {
final code = rawCode.trim();
if (code.isEmpty) return;
emit(const JoinSubmitting());
try {
final preview = await _householdRepository.peekInvite(code);
if (preview.requiresDisplayName) {
_pendingCode = code;
emit(JoinNeedsDisplayName(preview, suggestedName: preview.memberName));
return;
}
await _redeem(code, null);
} on InviteAcceptException catch (e) {
emit(JoinFailure(_inviteErrorCopy(e.reason)));
}
}

Future<void> submitWithName(String displayName) async {
final code = _pendingCode;
if (code == null) return;
emit(const JoinSubmitting());
try {
await _redeem(code, displayName.trim());
} on InviteAcceptException catch (e) {
emit(JoinFailure(_inviteErrorCopy(e.reason)));
}
}

Future<void> _redeem(String code, String? displayName) async {
final user = _authRepository.currentUser;
if (user == null) { emit(JoinFailure(Strings.joinWithCodeNotSignedIn)); return; }
final household = await _householdRepository.redeemAndActivate(
code: code, authUserId: user.id, displayName: displayName,
);
emit(JoinSuccess(household?.id ?? ''));
}

(Match _authRepository.currentUser + .id/.email to the real shapes already used by the old acceptInvite path in this cubit.)

  • Step 5: Update the sheet UI (join_with_code_sheet.dart): the BlocConsumer builder, when state is JoinNeedsDisplayName, renders a second DsTextField (key JoinWithCode.nameField, pre-filled with suggestedName) + a submit calling cubit.submitWithName(...). The code-step submit button calls cubit.previewAndSubmit(...) (was submit). Keep the success path (JoinSuccess → snackbar → router.replaceAll([MainShellRoute()])) unchanged.

  • Step 6: Add strings (strings.dart): joinWithCodeNotSignedIn, joinWithCodeNameFieldLabel ('Child's name'), joinWithCodeNameBody ('Add a name for this child to finish joining.'). Follow the existing joinWithCode* block.

  • Step 7: Run — verify PASS + app suite green.

  • Step 8: Commit

git add app/lib/inside/blocs/join_household app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart app/lib/inside/i18n/strings.dart app/test/unit/blocs/join_household_cubit_test.dart
git commit -m "fix(sp-b): join sheet auto-detects adult vs child code (live child-code bug)"

Task 6: "Join a household" entry in the switcher (KEEP BOTH)

Files:

  • Modify: app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart
  • Modify: app/lib/inside/i18n/strings.dart (householdSwitcherJoinWithCode)
  • Test: app/test/widget/household_switcher_join_entry_test.dart

Interfaces:

  • Consumes: showJoinWithCodeSheet (Task 5).

  • Produces: HouseholdSwitcherSheet.onJoin callback + a DsRow keyed HouseholdSwitcher.joinWithCode.

  • Step 1: Write the failing widget test — pump HouseholdSwitcherSheet with stub callbacks; assert a DsRow/widget with key HouseholdSwitcher.joinWithCode and text Strings.householdSwitcherJoinWithCode is present, and tapping it invokes onJoin.

  • Step 2: Run — verify FAIL.

  • Step 3: Implement — add final VoidCallback onJoin; to HouseholdSwitcherSheet; render a second DsRow (key HouseholdSwitcher.joinWithCode, Icons.group_add leading, Strings.householdSwitcherJoinWithCode) beside the existing createAnother row. In showHouseholdSwitcherSheet, wire onJoin: () { Navigator.of(context).pop(); showJoinWithCodeSheet(rootContext); } (capture the pre-sheet context exactly as the existing onCreateAnother does). Add the string.

  • Step 4: Run — verify PASS.

  • Step 5: Commit

git add app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart app/lib/inside/i18n/strings.dart app/test/widget/household_switcher_join_entry_test.dart
git commit -m "feat(sp-b): add 'Join a household' entry to the switcher (keep both surfaces)"

Task 7: First-run onboarding invite-code step (re-route to redeem + child branch + skip)

Files:

  • Modify: app/lib/inside/routes/authenticated/setup/page.dart
  • Modify: app/lib/inside/blocs/setup/bloc.dart (+ its state/event files)
  • Modify: app/lib/inside/i18n/strings.dart
  • Test: app/test/flows/setup_test.dart (extend the existing invite_code_joins_household story) + app/test/unit/blocs/setup_invite_redeem_test.dart

Design: For the FIRST-RUN wizard (isCreatingAdditional == false), the invite-code affordance becomes the wizard's opening move: the existing _NameCta ghost button (SetupPage.haveInviteCode) stays, but its dialog (_InviteCodeDialog / SetupInviteCodeSubmitted) is re-routed from acceptInvite to the auto-detecting peekInvite/redeemAndActivate path, with a child display-name branch. Proceeding with the normal Continue = the implicit "I don't have a code" skip (create-household). This honors "code first, with a skip" without adding a new SetupStep (YAGNI — the ghost CTA sits on step 1 above the create action). When isCreatingAdditional == true the ghost CTA stays hidden (SP-A behavior, unchanged).

  • Step 1: Write the failing bloc test (setup_invite_redeem_test.dart) with a mock repo: SetupInviteCodeSubmitted('adultcode') where peekInvite→adult preview and redeemAndActivate→household → bloc reaches its inviteAccepted state. Case 2: peekInvite→child preview → bloc emits SetupInviteNeedsName(preview)SetupInviteNameSubmitted('Robin')redeemAndActivate(displayName:'Robin')inviteAccepted. Case 3: InviteAcceptException(expired) → the existing typed-error state carrying Strings.setupInviteCodeExpired.

  • Step 2: Run — verify FAIL.

  • Step 3: Implement the bloc changes — in setup/bloc.dart, change the SetupInviteCodeSubmitted handler from acceptInvite(...) to peekInvite first; adult → redeemAndActivate; child → emit a new SetupInviteNeedsName(preview) state and handle a new SetupInviteNameSubmitted(name) event calling redeemAndActivate(displayName: name). Preserve the existing typed-error mapping (reuse the current _inviteErrorCopy + the same Strings.setupInviteCode* keys). Resolve authUserId the same way the handler already resolves the signed-in user.

  • Step 4: Update _InviteCodeDialog in setup/page.dart to render the child-name field (key SetupPage.inviteNameField) when the bloc is in SetupInviteNeedsName, submitting via SetupInviteNameSubmitted. The _NameCta ghost button + skip-by-Continue flow are unchanged.

  • Step 5: Add stringssetupInviteNameFieldLabel, setupInviteNameBody (follow the setupInviteCode* block).

  • Step 6: Extend the flow test — in setup_test.dart, update the existing invite_code_joins_household story to drive the auto-detect adult path (repo peekInvite→adult, redeemAndActivate→household), and add a child-attach screenshot segment (peek→child→name→redeem) within the SAME flowTest (one feature-set test, multiple stories — do not add a separate flowTest).

  • Step 7: Run — verify PASS + app suite green.

  • Step 8: Commit

git add app/lib/inside/routes/authenticated/setup/page.dart app/lib/inside/blocs/setup app/lib/inside/i18n/strings.dart app/test/flows/setup_test.dart app/test/unit/blocs/setup_invite_redeem_test.dart
git commit -m "feat(sp-b): first-run onboarding redeems any code (adult+child auto-detect)"

Task 8: Generate-side — optional bind-to-member picker + current-code legibility

Files:

  • Modify: packages/client_sdk/lib/src/models/household.dart (add hasActiveChildJoinCode)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (map child_join_code_hash != null)
  • Modify: app/lib/inside/blocs/household_settings/bloc.dart (+ state)
  • Modify: app/lib/inside/routes/authenticated/household_settings/household_child_join_code_sheet.dart
  • Modify: app/lib/inside/i18n/strings.dart
  • Test: packages/client_sdk/test/cloud/household_has_child_code_test.dart + app/test/unit/blocs/household_child_join_bind_test.dart

Design (honest scope): raw codes are NEVER stored (hash only), so the sheet cannot redisplay a previous raw code. "Surface the current code" therefore means: (a) show that a code IS active (household.hasActiveChildJoinCode) with a "regenerating replaces the previous code" legibility note; (b) make the current generate action the single, prominent, copyable source of truth. Bind-to-member (optional): the household-wide sheet gains a member picker; when a member is chosen, generation routes to issueChildAttachCode(memberId) (bound to that member); when none is chosen, it stays issueHouseholdChildJoinCode() (unbound → fresh member on redeem). Both already exist on the repository — this task only adds the picker + routing + legibility.

  • Step 1: SDK — expose hasActiveChildJoinCode. Add a bool hasActiveChildJoinCode field to Household (default false; include in copyWith/==/hashCode/JSON consistent with the model's existing pattern). Do NOT expose the hash itself.

  • Step 2: SDK test (household_has_child_code_test.dart): map a household row with a non-null child_join_code_hashhasActiveChildJoinCode == true; null → false. Run → FAIL → in the cloud Households mixin household row-mapper set hasActiveChildJoinCode: row['child_join_code_hash'] != null → PASS.

  • Step 3: App bloc — surface it + binding. In household_settings/bloc.dart, expose hasActiveChildJoinCode (from the loaded household) in state, and add a HouseholdChildJoinBoundCodeRequested(memberId) event calling _householdRepository.issueChildAttachCode(actingMemberId:..., memberId: memberId) (bound), alongside the existing unbound HouseholdChildJoinCodeRequested()issueHouseholdChildJoinCode().

  • Step 4: App bloc test (household_child_join_bind_test.dart): unbound event → repo issueHouseholdChildJoinCode called; bound event with a memberId → repo issueChildAttachCode(memberId:...) called. Run → FAIL → implement → PASS.

  • Step 5: Sheet UI (household_child_join_code_sheet.dart): in Phase 1, (a) when state.hasActiveChildJoinCode, show a Strings.householdSettingsChildJoinActiveNote line ("A join code is already active. Generating a new one replaces it."); (b) add an optional member dropdown (key HouseholdChildJoinCodeSheet.memberPicker) — "Anyone (creates a new profile)" vs a specific member; the generate button dispatches the bound or unbound event based on the selection. Keep the existing Phase 2 InviteCodeCard (copy/rotate/done) unchanged.

  • Step 6: StringshouseholdSettingsChildJoinActiveNote, householdSettingsChildJoinBindLabel, householdSettingsChildJoinBindAnyone. Follow the existing householdSettingsChildJoin* block.

  • Step 7: Run — verify PASS (SDK + app suites green).

  • Step 8: Commit

git add packages/client_sdk/lib/src/models/household.dart packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart packages/client_sdk/test/cloud/household_has_child_code_test.dart app/lib/inside/routes/authenticated/household_settings/household_child_join_code_sheet.dart app/lib/inside/blocs/household_settings app/lib/inside/i18n/strings.dart app/test/unit/blocs/household_child_join_bind_test.dart
git commit -m "feat(sp-b): generate-side optional member binding + current-code legibility"

Task 9: Feature-set flow test — redemption epic, multiple stories

Files:

  • Create: app/test/flows/invite_redemption_test.dart

Design: ONE flowTest('invite_redemption', …) under one EPIC, carrying MULTIPLE STORIES via FTDescription (epic→story→AC) and multiple screenshot segments — following app/test/flows/household_switch_test.dart exactly. Repositories are mocked via MocksContainer; stub peekInvite/redeemAndActivate seams. Stories:

  1. adult-code join (switcher → "Join a household" → enter adult code → peekInvite(adult) → redeem → shell).
  2. child-attach (join sheet: enter child code → peekInvite(child, requiresDisplayName) → name field appears → submit → redeemAndActivate(displayName) → shell).
  3. skip-to-create (first-run Setup: no code → Continue → create-household step — proves the skip).
  4. invalid/error (bad code → peekInvite throws InviteAcceptException → inline typed error, no navigation).
  • Step 1: Write the flow test — one flowTest with descriptions: enumerating the four stories + ACs, one screenshot(...) per story segment, arrangeBeforeActions to stub the repo seams, expectedEvents listing the cubit/bloc events + [ANALYTIC] route markers. Reuse warpToHome (1,2,4) and warpToSetup (3). Model structure/keys on household_switch_test.dart and setup_invite_error_test.dart.

  • Step 2: Run — verify PASS (cd app && fvm flutter test test/flows/invite_redemption_test.dart), then confirm the full app suite still green (fvm flutter test) with the golden baseline unchanged (screenshots added, none dropped).

  • Step 3: Commit

git add app/test/flows/invite_redemption_test.dart
git commit -m "test(sp-b): invite-redemption feature-set flowTest (adult/child/skip/error stories)"

Post-plan

  • Run graphify update . and commit (chore: graphify update after SP-B).
  • Whole-branch SP-B review (opus) over the SP-B commit range.
  • DEPLOY-GATED (owner): apply 20260723000100_peek_redeem_invite_rpc.sql to prod bgedvvmihygwxhjxlvfu → advisor check → then push + redeploy both URLs (app.rewhaven.com :8083, home.eldr-labs.duckdns.org/rewhaven/ :8080), in that order — the app calls peek_invite/redeem_invite, so the migration is a hard prerequisite for deploying, exactly like SP-A's account_active_household.

Self-Review notes (author)

  • Spec coverage: §1 unified redemption → Tasks 1–5,7. §2 code-first onboarding → Task 7. §3 join surfaces (KEEP BOTH) → Task 5 (re-route sheet) + Task 6 (switcher entry). §4 member binding + code hygiene → Task 8. Typed error handling → Tasks 2,5,7. Testing → Tasks 2–9 + the Task 9 feature-set flowTest. NOT-in-scope (child self-signup, SSO, merge, account admin) → excluded.
  • Honest deviations flagged for the reviewer/owner: (a) "surface the CURRENT code" cannot redisplay a prior raw code (hash-only at rest) — Task 8 implements presence + regenerate-to-copy legibility, which is the spec's actual intent ("make rotation legible"). (b) the already_member "offer to switch" UX is reduced to a typed error message in Task 5 (the switch-offer needs the exception to carry the household id; peek_invite already returns it, so a later enhancement is cheap — deferred to SP-D account admin). (c) first-run onboarding reuses the existing _NameCta ghost-CTA + dialog rather than adding a new SetupStep (YAGNI) — the affordance is on step 1 above the create action, satisfying "code first with a skip."
  • Type consistency: InvitePreview/InviteType defined in Task 2, used identically in 3–7; redeemAndActivate signature stable across 4→5→7; reason→exception mapping reuses InviteRejectionReason throughout; peekInviteRemote/redeemInviteRemote names consistent adapter↔service↔facade.