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 importsdrift/supabase/any I/O — only theclient_sdkfacade. - Typed error handling ONLY:
on <SpecificException>— NEVER barecatch (e), NEVER catchErroror its subtypes. ReuseInviteAcceptException(InviteRejectionReason)andChildLinkException(ChildLinkRejectionReason)frompackages/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 DarthashInviteToken), and arerevoke 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/; applyingpeek_invite/redeem_inviteto prodbgedvvmihygwxhjxlvfuis DEPLOY-GATED (owner-authorized only). All SDK/app tests model the RPCs inFakePostgrest— 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_invitepath. - 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
flowTestcarries MULTIPLE STORIES viaFTDescription(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 testin a package dir;fvm flutter testfor the app). Rungraphify 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. Hashesp_token, looks uphousehold_members.invite_token_hash, email-bound, returnsjsonb {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 ininfra/supabase/migrations/20260712000500_child_link_hardening.sql. Hashesp_code; ATTACH path viahousehold_members.invite_token_hashon akind='child'row; NEW path viahouseholds.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 PROBEScomment 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
Householdsmixin (packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart): existingacceptInviteRemotecallsdb.rpc('accept_invite', {'p_token': token}); reason→exception switches_inviteRejection/_childLinkRejectionat ~lines 254/270. StoragePort(packages/client_sdk/lib/src/adapters/adapter.dart): cloud-only verbs are namedxxxRemoteand throwUnimplementedErrorinInMemoryStorageAdapter+FakePort.HouseholdService(packages/client_sdk/lib/src/services/household_service.dart):acceptInviteat ~line 643 with a_useRemoteInviteAcceptflag; SP-AsetActiveHousehold(~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 singlerpcResponsefield (set one canned response per call). Cloud routing tests seed a fullhousehold_membersrow then setfake.rpcResponse.- Exceptions (
packages/client_sdk/lib/src/models/exceptions.dart):InviteAcceptException(InviteRejectionReason)reasonsinvalid|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)callsacceptInvite; statesJoinIdle{error}/JoinSubmitting/JoinSuccess(householdId)/JoinFailure(message)). - Switcher:
app/lib/inside/routes/authenticated/more/widgets/household_switcher_sheet.dart(showHouseholdSwitcherSheet,HouseholdSwitcherSheetwithonSelect/onCreateAnother;createAnotherDsRowkeyedHouseholdSwitcher.createAnother). - Setup:
app/lib/inside/routes/authenticated/setup/page.dart(SetupPage{isCreatingAdditional},SetupStepenum,_NameCta{showInviteCode,onHaveInviteCode},_InviteCodeDialogkeysSetupPage.inviteCodeField/Submit/Error) +SetupBlochandlingSetupInviteCodeSubmitted. - Repo:
app/lib/outside/repositories/household/household_repository.dart(acceptInvite,signUpChild,issueChildAttachCode,issueHouseholdChildJoinCode,inviteMember, SP-AswitchActiveHousehold/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), sharedmembers/invite_code_card.dart. - Guard/first-run:
app/lib/inside/routes/guards/authenticated_guard.dart(getHousehold()==null→SetupRoute()), wired inapp/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 errorsapp/test/flows/setup_invite_error_test.dart;MocksContainerinapp/test/util/mocks/mocked_app.dart; warps inapp/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 ofaccept_invite/link_childreasons plusnot_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 viadb.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(exportinvite_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>(returnshousehold_id; cloud-only)
-
Step 1: Write the
InvitePreviewmodel
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/lastRpcParamscapture toFakePostgrest
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-AsetActiveHousehold/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-activeHousehold.
-
Step 1: Write the failing repo test (
app/test/unit/household_repository_redeem_test.dart) — model onapp/test/unit/household_repository_active_test.dart. Use a fakeClient/SdkClientProvider. AssertredeemAndActivatecalls, in order:redeemInvite(code, displayName)→setActiveHousehold(authUserId, joinedHouseholdId)→bootstrapSession(authUserId), and returns the bootstrapped household. Add a second test thatpeekInviteis 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(besideswitchActiveHousehold):
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 stateJoinNeedsDisplayName(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 →
peekInvitereturns adult preview → cubit redeems →JoinSuccess(householdId). - child code →
peekInvitereturns child preview (requiresDisplayName:true) → cubit emitsJoinNeedsDisplayName; thensubmitWithName('Robin')→redeemAndActivate(displayName:'Robin')→JoinSuccess. expired/invalid/emailMismatch/alreadyMember→peekInvitethrowsInviteAcceptException(reason)→JoinFailurewith the mapped copy (reuse the existing_inviteErrorCopy).
- adult code →
-
Step 3: Run — verify FAIL.
-
Step 4: Implement the cubit — replace
submitwithpreviewAndSubmit+submitWithName+ private_redeem+ a_pendingCodefield:
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): theBlocConsumerbuilder, when state isJoinNeedsDisplayName, renders a secondDsTextField(keyJoinWithCode.nameField, pre-filled withsuggestedName) + a submit callingcubit.submitWithName(...). The code-step submit button callscubit.previewAndSubmit(...)(wassubmit). 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 existingjoinWithCode*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.onJoincallback + aDsRowkeyedHouseholdSwitcher.joinWithCode. -
Step 1: Write the failing widget test — pump
HouseholdSwitcherSheetwith stub callbacks; assert aDsRow/widget with keyHouseholdSwitcher.joinWithCodeand textStrings.householdSwitcherJoinWithCodeis present, and tapping it invokesonJoin. -
Step 2: Run — verify FAIL.
-
Step 3: Implement — add
final VoidCallback onJoin;toHouseholdSwitcherSheet; render a secondDsRow(keyHouseholdSwitcher.joinWithCode,Icons.group_addleading,Strings.householdSwitcherJoinWithCode) beside the existingcreateAnotherrow. InshowHouseholdSwitcherSheet, wireonJoin: () { Navigator.of(context).pop(); showJoinWithCodeSheet(rootContext); }(capture the pre-sheet context exactly as the existingonCreateAnotherdoes). 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 existinginvite_code_joins_householdstory) +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')wherepeekInvite→adult preview andredeemAndActivate→household → bloc reaches itsinviteAcceptedstate. Case 2:peekInvite→child preview → bloc emitsSetupInviteNeedsName(preview)→SetupInviteNameSubmitted('Robin')→redeemAndActivate(displayName:'Robin')→inviteAccepted. Case 3:InviteAcceptException(expired)→ the existing typed-error state carryingStrings.setupInviteCodeExpired. -
Step 2: Run — verify FAIL.
-
Step 3: Implement the bloc changes — in
setup/bloc.dart, change theSetupInviteCodeSubmittedhandler fromacceptInvite(...)topeekInvitefirst; adult →redeemAndActivate; child → emit a newSetupInviteNeedsName(preview)state and handle a newSetupInviteNameSubmitted(name)event callingredeemAndActivate(displayName: name). Preserve the existing typed-error mapping (reuse the current_inviteErrorCopy+ the sameStrings.setupInviteCode*keys). ResolveauthUserIdthe same way the handler already resolves the signed-in user. -
Step 4: Update
_InviteCodeDialoginsetup/page.dartto render the child-name field (keySetupPage.inviteNameField) when the bloc is inSetupInviteNeedsName, submitting viaSetupInviteNameSubmitted. The_NameCtaghost button + skip-by-Continue flow are unchanged. -
Step 5: Add strings —
setupInviteNameFieldLabel,setupInviteNameBody(follow thesetupInviteCode*block). -
Step 6: Extend the flow test — in
setup_test.dart, update the existinginvite_code_joins_householdstory to drive the auto-detect adult path (repopeekInvite→adult,redeemAndActivate→household), and add a child-attach screenshot segment (peek→child→name→redeem) within the SAMEflowTest(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(addhasActiveChildJoinCode) - Modify:
packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart(mapchild_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 abool hasActiveChildJoinCodefield toHousehold(default false; include incopyWith/==/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-nullchild_join_code_hash→hasActiveChildJoinCode == true; null → false. Run → FAIL → in the cloudHouseholdsmixin household row-mapper sethasActiveChildJoinCode: row['child_join_code_hash'] != null→ PASS. -
Step 3: App bloc — surface it + binding. In
household_settings/bloc.dart, exposehasActiveChildJoinCode(from the loaded household) in state, and add aHouseholdChildJoinBoundCodeRequested(memberId)event calling_householdRepository.issueChildAttachCode(actingMemberId:..., memberId: memberId)(bound), alongside the existing unboundHouseholdChildJoinCodeRequested()→issueHouseholdChildJoinCode(). -
Step 4: App bloc test (
household_child_join_bind_test.dart): unbound event → repoissueHouseholdChildJoinCodecalled; bound event with a memberId → repoissueChildAttachCode(memberId:...)called. Run → FAIL → implement → PASS. -
Step 5: Sheet UI (
household_child_join_code_sheet.dart): in Phase 1, (a) whenstate.hasActiveChildJoinCode, show aStrings.householdSettingsChildJoinActiveNoteline ("A join code is already active. Generating a new one replaces it."); (b) add an optional member dropdown (keyHouseholdChildJoinCodeSheet.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 2InviteCodeCard(copy/rotate/done) unchanged. -
Step 6: Strings —
householdSettingsChildJoinActiveNote,householdSettingsChildJoinBindLabel,householdSettingsChildJoinBindAnyone. Follow the existinghouseholdSettingsChildJoin*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:
- adult-code join (switcher → "Join a household" → enter adult code →
peekInvite(adult) → redeem → shell). - child-attach (join sheet: enter child code →
peekInvite(child, requiresDisplayName) → name field appears → submit →redeemAndActivate(displayName)→ shell). - skip-to-create (first-run Setup: no code → Continue → create-household step — proves the skip).
- invalid/error (bad code →
peekInvitethrowsInviteAcceptException→ inline typed error, no navigation).
-
Step 1: Write the flow test — one
flowTestwithdescriptions:enumerating the four stories + ACs, onescreenshot(...)per story segment,arrangeBeforeActionsto stub the repo seams,expectedEventslisting the cubit/bloc events +[ANALYTIC]route markers. ReusewarpToHome(1,2,4) andwarpToSetup(3). Model structure/keys onhousehold_switch_test.dartandsetup_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.sqlto prodbgedvvmihygwxhjxlvfu→ advisor check → then push + redeploy both URLs (app.rewhaven.com :8083, home.eldr-labs.duckdns.org/rewhaven/ :8080), in that order — the app callspeek_invite/redeem_invite, so the migration is a hard prerequisite for deploying, exactly like SP-A'saccount_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_invitealready returns it, so a later enhancement is cheap — deferred to SP-D account admin). (c) first-run onboarding reuses the existing_NameCtaghost-CTA + dialog rather than adding a newSetupStep(YAGNI) — the affordance is on step 1 above the create action, satisfying "code first with a skip." - Type consistency:
InvitePreview/InviteTypedefined in Task 2, used identically in 3–7;redeemAndActivatesignature stable across 4→5→7; reason→exception mapping reusesInviteRejectionReasonthroughout;peekInviteRemote/redeemInviteRemotenames consistent adapter↔service↔facade.