Skip to main content

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)

  1. 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]]).
  2. 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."
  3. 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 invited household_members row 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 the households row (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 a household_members row, and a child_join_code_hash on a households row. Return { ok, type: 'adult'|'child', household_name, member_name?, reason? }. reason carries 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_invite semantics (email-bound link of the invited member row); child → link_child semantics (attach, using p_display_name). Returns { ok, household_id, reason? }. On success the app sets that household active (SP‑A setActiveHousehold) 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) → setActiveHouseholdskip the create-household step, land in the shell.
    • Skip → the existing create-household step (name/emoji), unchanged.
  • 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_invite to the unified redeem_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 same peek_invite/redeem_invite path — 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_invite already 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_invite type detection) before redeem_invite.
  • All redemption failures are typed (on <SpecificException>; never bare catch, never catch Error) and surfaced as themed dialogs/inline errors.

Testing

  • SDK/RPC: peek_invite returns the right type + household name for an adult token, a child code, and not_found/expired/already_member/email_mismatch. redeem_invite routes 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 the peek_invite/redeem_invite migration to prod bgedvvmihygwxhjxlvfu is 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)

  1. 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_invite redemption path; they must remain behaviorally identical. (See §3.)
  2. 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.)