Invite Process Completion — Design Spec
Date: 2026-07-11
Status: Approved (brainstorming) — pending spec review → writing-plans
Supersedes/extends: docs/decisions/2026-07-04-role-governance.md (invite model, decisions 4-5), which established the two-shape / code-entry invite model and deferred delivery. This spec closes the gaps the deep-dive found and promotes delivery + deep links into scope.
Source analysis: .superpowers/sdd/invite-process-deep-dive.md (gaps G1–G14, recommendations R1–R8) and docs/decisions/2026-07-11-invite-process-cloud-accept-gap.md.
Goal
Make the member/co-parent invite feature actually work end-to-end in the production (cloud) topology — a real cross-account person can be invited, receive the invite, accept it, and be linked into the household — with the security, hygiene, and durability the feature needs for launch.
Why (the break)
The invite feature is a well-built inviter-side domain model with a broken acceptance leg in cloud. acceptInvite (household_service.dart:535) is a caller-household read-modify-write; under RLS a not-yet-member invitee can neither read the household/invited row nor perform the linking update, and there is no SECURITY DEFINER accept RPC / Edge Function. Production runs DataMode.cloud. Additionally the co-parent invite code is never surfaced (discarded in members_bloc._onSaved), so the flagship "invite Mom" flow dead-ends, and there is no delivery channel. CI missed all of this because every invite test runs against in-memory storage with no RLS.
Decisions (locked in brainstorming)
- Acceptance is EMAIL-BOUND. Redeeming a code requires the accepting account's email to match the invite's email (
auth.email() == invite.email). The code alone is insufficient — a leaked/forwarded code cannot be redeemed by the wrong person. Right posture for a family-data app; pairs naturally with email delivery. - Invite token is HASH-STORED. The raw 192-bit token is shown to the inviter once at creation/resend and never persisted; only a hash is stored (
invite_token_hash). Removes the plaintext-token leak via roster reads (G3) on top of email-binding. - Durable invite EVENT TRAIL. An append-only
invite_eventslog records issue/resend/revoke/accept/expire/delete, so a lost invite is discoverable and re-issuable — the Mom-incident durability fix (G4). We keep the member-row-as-invite model; the event trail gives durability without the larger first-class-invites-table restructure (post-MVP). - JOIN-WITH-CODE for existing accounts is in scope (G5): a "Join a household with a code" surface in Settings, plus the existing Setup-page entry for brand-new accounts. Joining makes the joined household the active one (single-household, newest-first
memberForAccount), gated behind a confirmation; a full multi-household switcher stays post-MVP (G12). - DELIVERY is in scope (reverses the prior deferral): the invitee receives the invite by email containing an actionable deep link.
- Deep links = Android App Links (production target is an Android app) + web
?invite=param (current web/UAT deployment), unified behind one in-app incoming-link handler. Manual code entry remains the always-available fallback. - Android-release-gated pieces are sequenced, not blocking. No signed Android build/release exists yet (a Google Play account does). The app-side deep-link plumbing + email + web-link work is built now; the domain
assetlinks.json(needs the prod signing-cert SHA-256, available after first Play upload / Play App Signing) and the Play Store fallback URL are completed when the Android release lands.
Architecture — two layers, one feature
The invite core (Layer 1) is independent of the delivery/transport (Layer 2). Layer 1 makes the invite work (via code entry); Layer 2 makes it seamless (email + tap-a-link). The plan phases Layer 1 first (unblocks the feature) then Layer 2.
Layer 1 — Invite core
Schema / Postgres (infra/supabase/migrations/):
accept_invite(p_token text)— SECURITY DEFINER, pinnedsearch_path='public',revoke execute from public, anon, grant toauthenticated. Atomically:- hash
p_token, look up the member row byinvite_token_hash(unique/partial-unique index); - verify: found ·
expires_at > now()· targetauth_user_id is null(unlinked) · email-boundlower(invite.email) = lower(auth.email())· the acceptingauth.uid()does not already have a member row in that household (already-member guard, G7); - on success: set
auth_user_id = auth.uid(),status='active', clearinvite_token_hash+expires_at; insert aninvite_eventsacceptedrow; return the household id; - each failure returns a distinct typed reason (
invalid/expired/email_mismatch/already_member/already_linked) mapped by the SDK to a typed exception → specific UI copy (replaces the generic "invalid code", G11).
- hash
invite_token_hash textcolumn onhousehold_members(replaces plaintextinvite_token); migrate existing pending rows (hash or clear) — the only recent live pending invite (Mom) was already deleted, so the back-population set is expected empty/small; verify before applying.invite_eventsappend-only table:id, household_id, member_ref (nullable), email, kind (issued|resent|revoked|accepted|expired|deleted), actor_auth_user_id, created_at, metadata jsonb. RLS: SELECT for members of the household; INSERT only via the service/RPC (definer) path.- Legacy
{admin}backfill (G9):update household_members set roles='{member}' where status='invited' and roles && array['admin','helper']. - Pending-email dedupe (G6): a guard and/or partial unique index
(household_id, lower(email)) where status='invited'so a second co-parent invite to the same email resends rather than minting a duplicate placeholder. - Server-side expiry enforced inside the RPC (G10); lazy/scheduled cleanup of long-expired placeholders is a small non-blocking follow-up.
SDK (packages/client_sdk):
inviteCoParent/inviteMember/resendInvite: generate the raw token, store onlyinvite_token_hash, return the raw token to the caller, write anissued/resentinvite_eventsrow.revokeInvitewritesrevoked(co-parent shape deletes the placeholder row per existing rule; account shape clears invite fields).acceptInvite(rawToken): in cloud mode routes through theaccept_inviteRPC (pass the raw token; RPC hashes + checks); local/in-memory keeps the direct path but adds the same email-bound + already-member checks for parity. Map RPC typed reasons → typed exceptions.- Acting-member gating on the co-parent invite verbs (
inviteCoParent/resendInvite/revokeInvite) for dual-gate symmetry (G8, partial — full admin-scoped RLS for invite-column mutations is a fast-follow if the RPC-only mutation path doesn't already cover it). - Expose an
invite_eventsread (minimal — supports a future history/re-issue UI; the durability value is the persisted trail; UI can be thin now).
App UI (app/lib/inside):
- Surface the co-parent code (G2): after
inviteCoParentand after resend, show the existing_CodeViewshare sheet (copy + regenerate + "you won't see this again"). Stop discarding the returned member inmembers_bloc._onSaved/_onInviteResent. - Join-with-code (G5): a "Join a household with a code" entry in Settings/More that calls
acceptInvite; keep the Setup-page entry for new accounts. On success route to the joined household; confirm that this becomes the active household (single-household + newest-first). - Typed accept errors (G11): map the RPC reasons to specific copy on both surfaces.
Layer 2 — Delivery + deep links
send-inviteEdge Function (infra/supabase/functions/send-invite/, service-role): on invite, email the invitee a link (https://rewhaven.com/invite?token=<raw>) + the code as fallback text. MVP uses Supabase's built-in invite/magic-link email (auth.admin.inviteUserByEmailwithredirectTo, or a templated send); a branded mail provider (Resend/SendGrid) is an easy later swap — the Edge Function is the seam.- In-app incoming-link handler: the
app_linkspackage receives the Android App Link intent (cold-start + warm) and the web?invite=query param; a single handler extracts the token → routes to the accept flow → auto-submitsacceptInvite. Works on web now (UAT) and native on release. - AndroidManifest intent-filter for the
rewhaven.com/invitepath (autoVerify=true). - Marketsite repo (
rytedesigns/rewhaven-marketsite, Cloudflare Worker): host/.well-known/assetlinks.jsonand an/invitelanding page (app installed → App Link opens the app; not installed → "Get the app" → Play Store, and display the code for manual entry). - Android-release-gated (flagged dependency, not blocking Layer 1): publish
assetlinks.jsonwith the production signing-cert SHA-256 (from Play App Signing after first upload); the Play Store fallback URL; on-device App Link verification.
Data flow (happy path)
Inviter (admin) → inviteCoParent(email) → raw code shown once + hash stored + issued event → send-invite emails the invitee a rewhaven.com/invite?token=… link → invitee taps → (Android App Link opens the app, or the web app reads ?invite=, or not-installed → Play Store → install → link) → invitee signs in with the invited email → app auto-submits acceptInvite(token) → accept_invite RPC (email-bound, atomic) links + activates + clears + writes accepted event + returns household id → app routes into the household.
Error handling
- Typed reasons end-to-end (RPC reason → SDK typed exception → specific UI copy).
- Atomic RPC (no partial link); single-use (hash cleared on accept); expiry server-enforced; already-member + email-mismatch rejected with distinct copy.
- Offline/local mode: direct path with parity checks; delivery/deep-link is a cloud concern.
Security
- Email-bound acceptance (decision 1). Hash-stored token, not roster-readable (decision 2). SECURITY DEFINER RPC with pinned
search_path+revoke execute from anon(house pattern). Legacy{admin}invited rows demoted (G9) so a stale invite can't grant admin. Acting-member gating on invite verbs (G8). Children remain non-invitable (COPPAisAdultguard is the single seam, G13 — unchanged).
Testing
- The gap that hid the break: a two-auth-identity cloud test — inviter and invitee are different Supabase users under RLS — proving accept works across identities (and is denied for the wrong email / expired / already-member). Load-bearing new coverage.
- SDK unit: invite/resend/revoke/accept, email-bound accept, hygiene guards, event-trail writes, typed reasons.
- App flow tests: surface-the-code, join-with-code, typed accept errors, incoming-link handler routes to accept.
- Live smoke: two-identity accept on the deployed RPC (house style).
- Edge Function + deep-link handler: unit/integration where feasible; on-device App Link verification is part of the Android-release step.
Out of scope / deferred
- First-class
invitestable restructure (the event trail covers durability for now) — post-MVP structural. - Full multi-household switcher UX (newest-first + join confirmation stands, G12).
- Branded mail-provider email (start on Supabase built-in) — easy swap.
- Native App Link verification (assetlinks with prod cert + Play fallback) — sequenced with the Android release.
- Child/kid user accounts (COPPA hard gates;
isAdultguard is the seam).
Dependencies / coordination
- Supabase project
bgedvvmihygwxhjxlvfu: the accept RPC +invite_events+ hash column + backfill migrations (applied live after review, house guard-RPC pattern); thesend-inviteEdge Function (service-role — deployed, not client-called). - Marketsite repo
rytedesigns/rewhaven-marketsite:assetlinks.json+/invitelanding (separate repo, separate deploy). - Android release (Play account exists; build/signing/listing not yet): required to finalize App Link domain verification + store fallback. Layer 1 + Layer 2 app-side/email do not block on it.
Success criteria
A person invited by email can, from a different account/device, receive the invite, accept it (email-bound), and be linked + active in the household — verified by a two-identity cloud test and a live smoke — with the code+manual-entry path working immediately and the tap-a-link path working on web now and on Android once released.