SP‑B — Code-First Onboarding + Invite Redemption + Share-Code↔Member Binding — Design Spec
Date: 2026-07-22
Status: Draft for owner review
Initiative: Onboarding + invite + account-management + multi-household (sub-project B of 4). Vision: docs/decisions/2026-07-22-onboarding-invite-account-management-vision.md. Decomposition: SP‑A (foundation, spec+plan done) → SP‑B (this doc) → SP‑C (delete-and-merge) → SP‑D (account/user-management admin).
Depends on: SP‑A (multi-household foundation) — joining a household = adding a membership + setting it active. Branch target: feat/mvp1-personas-authz.
Goal
Make the invite code the first step of onboarding, redeem any code through one auto-detecting path (fixing the live bug where child codes couldn't be redeemed), and let an admin bind a generated code to an existing member so the joiner auto-links to that profile. Also stop the "stale rotated code" confusion by surfacing the current active code.
Locked decisions (owner, 2026-07-22)
- Child scope = parent-initiated child attach only (
link_child): a parent creates the child's profile + code, the child redeems it on a supervised/child-auth device to attach. Needs no new VPC gate (the parent already created + consented). Child self-signup (from-scratch) is DEFERRED behind the COPPA VPC gate (see [[rewhaven-coppa-legal-gate-deferred]]). - Redemption UX = auto-detect + branch. One code field; the system detects adult-vs-child and continues (child codes then collect the child's display name; adult codes link directly). The joiner never needs to know their code's "type."
- SSO is a SEPARATE feature (out of SP‑B). SP‑B runs on the existing email/password auth.
What already exists (reuse, don't rebuild)
accept_invite(p_token)— SECURITY DEFINER RPC, adult, email-bound; UPDATEs a pre-created invitedhousehold_membersrow to link the account (so it already binds to an existing member). Tokens stored hashed (encode(digest(token,'sha256'),'hex'), symmetric).link_child(p_code, p_display_name)— SECURITY DEFINER RPC, child-attach; the child-join code is hashed on thehouseholdsrow (child_join_code_hash).- These work (proven by the 2026-07-22 investigation). The live bug is UX-level: the More-tab "join with invite code" sheet only ever calls
accept_invite, and the owner pasted a rotated code. SP‑B routes correctly + surfaces the current code.
Architecture
1. Unified auto-detect redemption: peek_invite + redeem_invite
Two SECURITY DEFINER RPCs wrap the existing accept_invite/link_child logic — routing/detection, not a rewrite:
peek_invite(p_code text) → jsonb(read-only, no side effects): hash the input; look it up across BOTH spaces — an adult invite-token-hash on ahousehold_membersrow, and achild_join_code_hashon ahouseholdsrow. Return{ ok, type: 'adult'|'child', household_name, member_name?, reason? }.reasoncarries a typed failure (not_found/expired/already_member/email_mismatch_hint). This lets the UI show "Join the Smith household?" and, for a child code, prompt for the display name before committing.redeem_invite(p_code text, p_display_name text default null) → jsonb: detect type (same lookup), then call the existing internal logic — adult →accept_invitesemantics (email-bound link of the invited member row); child →link_childsemantics (attach, usingp_display_name). Returns{ ok, household_id, reason? }. On success the app sets that household active (SP‑AsetActiveHousehold) and re-bootstraps.
Detection is deterministic: an adult token and a child code hash into different columns/tables, so a single lookup routes unambiguously. If a code matches neither → not_found (the "stale/rotated code" case gets a clear message, not a silent adult-only failure).
Adult codes remain email-bound (existing accept_invite constraint): the joiner must be signed in with the invited email. peek_invite surfaces an email_mismatch_hint so the UI can say "this invite is for a different email" instead of a generic failure.
2. Code-first onboarding
For a brand-new account (SP‑A getHousehold() returns null → the Setup wizard):
- Step 1 = invite code with a clear "I don't have a code" skip.
- Enter code →
peek_invite(show the household + type; for child, collect display name) →redeem_invite→ join (membership added) →setActiveHousehold→ skip the create-household step, land in the shell. - Skip → the existing create-household step (name/emoji), unchanged.
- Enter code →
- SP‑A already added
SetupPage.isCreatingAdditional; SP‑B adds the code step to the first-run path. An already-onboarded user creating/joining another household uses the More-tab entries (below), not this first-run wizard.
3. Join surfaces (one redemption path, three entry points)
- First-run onboarding (step 1, above).
- More-tab "Join a household" — a new entry beside SP‑A's "+ Create another household" in the household switcher, for an already-onboarded account joining a second household (pairs with SP‑A multi-household: redeem → add membership → the household appears in the switcher).
- The current More-tab "join with invite code" sheet — re-routed from the adult-only
accept_inviteto the unifiedredeem_invite(this is the direct fix for the live bug). KEEP BOTH (owner decision, 2026-07-23): this re-routed sheet AND the "Join a household" switcher entry both stay. Both invoke the samepeek_invite/redeem_invitepath — the switcher entry is the discoverable multi-household affordance; the re-routed sheet is the direct live-bug fix and a familiar entry point. They must stay behaviorally identical (one redemption path, two entry points) so there is no divergence to keep in sync beyond the shared call site.
4. Share-code ↔ member binding + code hygiene (generate side)
- Bind a generated code to an existing member. When an admin generates an invite/share code, they can pick an existing member (a shadow/placeholder profile) to bind it to, so the joiner auto-links to that profile (inheriting its roles/assignments) instead of creating a fresh member.
accept_invitealready binds to a pre-created invited row; SP‑B generalizes the generate UI to choose which member — for both adult (invite-token on the member row) and child (child-join code) paths. - Surface the current active code. The admin invite/code UI shows the current active code with copy + a "last generated / regenerates on tap" note, so users stop pasting rotated/stale codes (the other half of the live-bug root cause). Because codes hash-rotate on every generate, only the newest is valid — the UI must make that legible.
Error handling
not_found(stale/rotated/mistyped): a clear "that code isn't valid — ask for the current code" message (not the old generic adult-only error).expired: distinct message; offer to request a fresh code.already_member: friendly "you're already in this household" → offer to switch to it (SP‑A).email_mismatch(adult, email-bound): "this invite is for a different email — sign in with that email."- Child code without display name: the UI must collect it (from
peek_invitetype detection) beforeredeem_invite. - All redemption failures are typed (
on <SpecificException>; never barecatch, never catchError) and surfaced as themed dialogs/inline errors.
Testing
- SDK/RPC:
peek_invitereturns the right type + household name for an adult token, a child code, andnot_found/expired/already_member/email_mismatch.redeem_inviteroutes adult → link, child → attach (with display name), and sets no state on failure. Two-identity RLS: a peek/redeem only works for the calling account; codes remain hashed (never returned in plaintext). - App/flow: first-run onboarding — valid adult code → joins + skips create; valid child code → collects name → attaches; skip → create-household. More-tab "Join a household" adds a 2nd membership (appears in the SP‑A switcher). The current sheet's child-code path now succeeds (regression test for the live bug). Generate-side: binding a code to a chosen member → redemption links to that member.
- Reuse the SDK test doubles + flow-test harness; baselines must not drop.
Scope boundary
In SP‑B: peek_invite + redeem_invite (+ migration for the two RPCs, deploy-gated); the onboarding code-step; the More-tab "Join a household" entry + re-routing the current sheet; generate-side member binding + current-code surfacing; typed redemption errors.
NOT in SP‑B: child self-signup (COPPA VPC — deferred); SSO (separate feature); delete-and-merge (SP‑C); the polished account/user-management admin surface (SP‑D — SP‑B's generate-side + join entries are minimal, expanded by SP‑D); multi-household switching itself (SP‑A).
Global constraints
- One data path: Bloc → Repository → Client facade → Service → Adapter; presentation never imports drift/supabase.
- Invariants in service AND schema: the redemption RPCs are SECURITY DEFINER + validate membership/consent server-side; codes stay hashed; RLS unchanged (redemption is via RPC, not direct table writes).
- Migrations file-only under
infra/supabase/migrations/; applying thepeek_invite/redeem_invitemigration to prodbgedvvmihygwxhjxlvfuis deploy-gated (owner-authorized). App/SDK tests use the in-memory adapter (model the two RPCs there). - FVM only; no brand strings in package/class/RPC names; all copy via
Strings; typed error handling only. - COPPA: the child-attach path is parent-initiated (no new consent gate); do NOT add a child self-signup entry (that re-raises the deferred VPC gate).
Owner decisions (resolved 2026-07-23)
- Join surfaces — KEEP BOTH. The More-tab "Join a household" switcher entry (SP‑A-aligned) AND the re-routed "join with invite code" sheet both stay. Two entry points, one shared
peek_invite/redeem_inviteredemption path; they must remain behaviorally identical. (See §3.) - Bind-to-member default — OPTIONAL BINDING; UNBOUND = FRESH MEMBER. Binding a generated code to an existing member is optional. A code generated without a chosen member creates a fresh member on redemption (preserving today's un-bound adult-invite behavior). Binding is never required. (See §4.)