Appearance
Marriage Biodata — phased implementation plan
Target: 10 September 2026. Task lists for the actual build, written to be executed rather than read. Architecture comes from Consumer decisions, which is the SSOT; this page is the sequencing and the work breakdown.
The organising thesis, and it is the reason this plan exists in parallel with the design work
Claude Design gates the SCREENS and nothing else. Schema, RLS, Edge Functions, the recipient page's data contract, the keystore, the auth migration and the production fix are all design-independent. So the plan is built so that every non-screen task completes during the design rounds, and when screens land the remaining work is composition against interfaces that already exist and are already tested.
Sequenced by irreversibility, not visibility. Screens are last because screens rebuild cheaply and a wrong schema does not.
Capacity. One developer plus Claude Code, 29 Aug to 10 Sep. 2026-08-29 is a Saturday, so the weekends are 29-30 Aug and 5-6 Sep; Mon 31 Aug and Mon 7 Sep are working days. Roughly 9 working days plus 4 weekend days at partial rate ≈ 11.4 developer-days. Three independent plans scoped this work at 11.0, 11.9 and 14.9 days, so this is tight and the cut list in §7 is not decoration.
Phase 0 · Ground — blockers that are not features
Nothing else is verifiable until these are true. Do them first, in this order.
| # | Task | Where | Done when | Est |
|---|---|---|---|---|
| 0.1 | Fix production. Redeploy apps/web with Worker vars asserted | deploy:manual production, or deploy-web.yml dispatch after 1 Sep | curl qrsetu.com/<slug>/setu-card returns 404 not 500, and /merchant returns 200 | 0.5d |
| 0.2 | Generate a real Android upload keystore | apps/mobile/android/app/build.gradle, gradle.properties | release no longer references signingConfigs.debug; keystore lives off the repo and off C:\; apk analyzer shows the new signer | 0.5d |
| 0.3 | ✅ MEASURED 2026-08-31. Auth path for the Google-only users | auth.identities on Dev | See below. Severity corrected | 0.5d |
| 0.4 | ✅ RESOLVED 2026-08-31, no work needed | packages/data/src/auth/service.supabase.ts:69-80 | See below | — |
0.4 · Resolved: the seam was already built for this
signInWithOtp already passes options.data, which lands in raw_user_meta_data where handle_new_user reads primary_context. That is a generic Supabase Auth option rather than an email-specific one, so the phone path inherits it. And the code already anticipated the failure case in its own comment: "IT APPLIES ONLY TO AN ACCOUNT BEING CREATED… this cannot repair an existing row — setPrimaryContext is that path, and is also the only option for OAuth, where the provider owns the metadata entirely." setPrimaryContext throws rather than degrading, because "swallowing a failure would leave a consumer recorded as a merchant… and do it silently."
⚠ The residual is an implementation note, not an architecture gap: whoever writes sendPhoneOtp must pass options.data the same way and call setPrimaryContext on the individual branch after verify.
0.3 · Measured on Dev, and the severity was overstated
sql
select provider, count(*) from auth.identities group by provider; -- google: 7, nothing else
select count(*) filter (where email_confirmed_at is not null), -- 7
count(*) filter (where phone is not null), -- 0
count(*) filter (where encrypted_password is not null), -- 0
count(*) filter (where last_sign_in_at > now() - interval '30 days') -- 6
from auth.users;7 users, all Google, all with a confirmed email, none with a phone or a password, 6 active in the last 30 days. So they are live accounts and every one is reachable.
⚠ These are DEV accounts. Production sits on a separate Supabase account not visible from this session (list_projects returns only qr-setu-dev and qr-setu-legacy-bkp), and releases/production-state.md:23 records "Nothing has shipped through this system yet." So the realistic blast radius is the team losing access to Dev, not customers being locked out. QRS-910 is corrected from P0 to P2 on that basis.
Recommended path, with a measurable exit condition rather than a date: add a phone identity to each account before the Google provider is removed, and keep Google enabled until select count(*) from auth.users where phone is null reaches 0. Six of seven are active, so an in-app prompt reaches them; the seventh needs one message.
⚠ Email OTP is not the bridge, even though all seven have a confirmed email: it is broken at the SMTP layer (QRS-285), so that path would have to be fixed first.
⚠ And keeping Google has a cost worth naming: Apple Guideline 4.8 requires Sign in with Apple because a social login is offered. Retaining Google as a legacy path retains that obligation, and with it the stripped-entitlement workaround and its Service-ID/Key-ID/.p8 tail.
Tracked as QRS-911 · QRS-908 · QRS-910.
Phase 1 · Irreversibles — schema and identity
These are the decisions that get expensive after the first real family uses the product. Dev is greenfield and CLAUDE.md grants standing authority to redesign, so today they are cheap; that stops being true on 10 September.
| # | Task | Done when | Est |
|---|---|---|---|
| 1.1 | ✅ DONE 2026-09-02 — slugs registry migration per D1 (QRS-980) | ✅ All four asserted on BOTH environments: ON DELETE SET NULL on both owner FKs, state, reclaim_hmac, num_nonnulls(...) <= 1. Plus RLS on with 0 client grants, and extensions.citext rather than a bare one (QRS-979 — a bare reference passes locally and FAILS on Dev) | 0.5d |
| 1.2 | ✅ DONE 2026-09-02 — backfill + FK setu_cards.slug (QRS-982) | ✅ Verified on Dev: 5 cards, 5 registry rows, 0 unregistered. FK is NO ACTION both ways on purpose (a card delete leaves the name; a name in use cannot be deleted). ⚠ Ships a bridging trigger because a bare FK would have broken provision_merchant_workspace on the next signup — 1.3 retires it. Behavioural pgTAP still owed (QRS-983) | 0.25d |
| 1.3 | ✅ DONE 2026-09-03 — claim + availability RPCs (QRS-984) | ✅ Asserted, not described: resolve_slug_status('priya-sharma') and resolve_slug_status('admin') are byte-identical, and a released address matches both. Four objects; resolve_setu_card_slug_status is now a thin wrapper so the two can never drift. ⚠ The bridging trigger is hardened, not retired — retiring it would put correctness back on a complete grep (QRS-985). ⚠ claim_slug is service_role-only, so no client can claim yet: manage-account is its home, in 1.4. Residual oracle tracked as QRS-986 | 0.5d |
| 1.4 | ⚠ HALF DONE 2026-09-03. The claim action shipped (QRS-984, CR-119) because the consumer home's identity card is unshippable without it. The soft delete is still open | ⚠ The middle clause is already true by construction — slugs_release_on_owner_loss (CR-118) releases an ownerless row whether the delete is hard or soft, and a released slug reports unavailable (pgTAP §C/§F). What the soft delete still buys is the reclaim_hmac: a hard delete loses the contact it derives from, so a person who returns can never prove a claim to their own name | 0.5d |
| 1.5 | Widen media_scope_exactly_one to three-way | workspace_id | conversation_id | owner_user_id, exactly one | 0.25d |
| 1.6 | Wire the private R2 bucket | _shared/r2.ts takes a bucket; manage-media stops hardcoding bucket: 'media'; a consumer photo lands in private | 0.75d |
| 1.7 | biodata_subjects + marriage_biodatas + disclosure tiers + biodata_grants | Tables, RLS, and the one-active-per-subject partial unique index | 1.0d |
| 1.8 | feature_grants entry capping subjects at 1 | The owner's one-per-account rule holds, enforced by entitlement not schema | 0.25d |
| 1.9 | pgTAP: a consumer reads nothing merchant-owned; an anon token read returns only its tier | npm run test:db green, negative assertions included | 0.75d |
| 1.10 | D6 the profile number — sequence, Feistel permutation in the write path, bigint column, unique index | QRS 482 011 735 renders; the key is an EF secret, never in pg_proc; two adjacent rows have unrelated references | 0.5d |
| 1.11 | D7 versioning — biodata_templates sibling table, retire guard, template_key/template_version on the record | A version cannot be retired while a row pins it; no FK from the record to the registry | 0.5d |
| 1.12 | Structured person model — family_members jsonb, replacing the ·-separated string | Three family layouts and the vouching list read objects; nothing splits on a delimiter | 0.25d |
| 1.13 | media | — | 0d |
⚠ 1.10 and 1.11 are the two that are expensive after launch. A reference format ends up on printed cards and in people's phones, and retrofitting versioning once templates exist is a migration. Both are cheap now and neither is cheap later.
⚠ 1.13 is blocked on a sourcing decision, not on engineering. Raja Ravi Varma (d. 1906) is public domain and covers the pan-Hindu deities, but Swami Samarth, Gajanan Maharaj and Vitthal — the three carrying most Maharashtra usage — are not in that corpus. Licensing is per file, never per category, and a photograph of a murti can carry its own copyright even when the murti is ancient.
⚠ 1.9 is not optional coverage. CLAUDE.md requires a pgTAP suite asserting an authenticated user with no workspace can read nothing merchant-owned, and consumers make authenticated mean the logged-in general public for the first time.
Total phase 1: ~4.75d. This is the phase that must not be compressed.
Phase 2 · Backend surface
| # | Task | Done when | Est |
|---|---|---|---|
| 2.1 | manage-biodata Edge Function (create, edit, publish, close, retire) | Co-located Deno tests green: success, validation, authz, safe 500s | 1.0d |
| 2.2 | Public read RPC for the recipient page, token-scoped | Returns only the tier the token grants; SECURITY DEFINER, REVOKE ALL FROM PUBLIC, explicit GRANT | 0.5d |
| 2.3 | Rate limit on the anonymous read | A limit exists at some layer. There is currently none anywhere in the repo | 0.5d |
| 2.4 | Subject notification via WhatsApp per D3 | One message on publish, with view + removal links; skipped when subject is the account holder | 0.5d |
| 2.5 | packages/schemas Zod + packages/data seam with a real Supabase impl | Barrel binds the real service, not a stub. check:rpc green | 0.5d |
| 2.6 | Cache purge on write | Editing a biodata invalidates the recipient page. Reuse invalidateCardCache's pattern | 0.25d |
Total phase 2: ~3.25d. Phases 1 and 2 together are ~8d and entirely design-independent — which is the whole argument for running them during the design rounds.
Phase 3 · WhatsApp OTP onboarding
Runs against Consumer decisions §2 and D8. Full reference: WhatsApp OTP playbook.
✅ THE META SIDE IS DONE, 2026-09-01. THIS PHASE IS NOW HALF THE SIZE IT WAS PLANNED AT.
An OTP delivers to a real Indian handset in about a second, in English and Marathi, from the production number with the QR setu logo attached. 3.2 was the external dependency on the critical path and it is spent (QRS-932).
⚠ And the risk that justified calling it critical is gone entirely: delivery reached a handset with no role on the app and on no allowlist while the app is Unpublished, so App Review is not a launch prerequisite. QRS-919 is no longer a schedule risk.
| # | Task | Done when | Est |
|---|---|---|---|
| 3.0 | ✅ DONE. Meta account, production WABA, registered Cloud API number, business profile and branding | can_send_message: AVAILABLE on WABA, BUSINESS and APP; number CLOUD_API + CONNECTED + VERIFIED | — |
| 3.2 | ✅ DONE. Authentication template | qrsetu_otp APPROVED on creation in en/mr/hi. Auth templates skip the review queue, so this was never the days-long queue it was planned as | — |
| 3.1 | Supabase Send SMS Hook Edge Function delivering via Meta Cloud API | A real code arrives through the hook rather than a script. Never Supabase's native channel:'whatsapp', which is Twilio-only | 1.0d |
| 3.1a | Webhook receiver in the same function, subscribed to the WABA | Delivery receipts and failure codes reach us. Until this exists a failed send is indistinguishable from a successful one, which cost most of a day already | 0.5d |
| 3.1b | Align the two expiries | Supabase OTP expiry set to 600s to match the template's code_expiration_minutes: 10. Mismatched, a person is told a live code is dead, or types a code the message shows as live and is refused | 0.1d |
| 3.1c | ⚠ PROBE FIRST, BEFORE 3.1 IS DESIGNED: does signInWithOtp({ phone, options: { data } }) write raw_user_meta_data? | Answered against Dev. If it does not, handle_new_user defaults to 'business' and every consumer is provisioned as a merchant (QRS-910) | 0.1d |
| 3.3 | Phone OTP path in the apps/mobile auth feature | Sign-up and sign-in both work; the Google-only accounts are unaffected. Carries QRS-917: the account type must ride on the OTP send, not be corrected at celebrate | 0.5d |
| 3.3a | Rate limiting on the send endpoint (QRS-921) | Two independent limits, per phone number and per source. Each message costs money, so an unthrottled endpoint is a direct drain, not just a security gap | 0.5d |
| 3.5 | PIN / Face ID gate + recovery | expo-local-authentication added with a size callout; forgotten-PIN recovery re-runs the OTP. Design is built (PinGate) | 1.0d |
⚠ 3.4 is DELETED. It asked for a slug step in INDIVIDUAL_STEPS. Design rounds 11 to 14 settled the consumer flow as language → phone → otp → name with the slug claimed later from My QR setu, and the language step then moved to the choose screen. Adding a slug step now would contradict the shipped design and re-erect the wall in front of browsing that D1 removed.
⚠ 3.5 is still the first cut candidate — see §7. Nothing else in this phase is.
🔎 Two things a reader of the old plan should not carry forward: the display name showing as a raw number is name_status: PENDING_REVIEW on Meta's side and needs no work from us, and the OTP body copy cannot be customised at all because Meta owns it for authentication templates. Both are covered in the playbook.
Phase 4 · Screens — gated on Claude Design
⚠ THIS PHASE IS SUPERSEDED BY THE 2026-09-04 RECONCILIATION — READ THAT PAGE, NOT THIS TABLE
The six rows below were written against design round 2. The design is now at round 40 for ConsumerHome and rounds 3–10 for the biodata module, and the owner's brief of 2026-09-04 narrowed the release to the consumer identity hub, Marriage Biodata, Account and the chrome that reaches them, with the marketplace OFF (which the design itself defaults to). The remaining screen work, the backend gaps that block it, the release-versus-later line and the execution order (waves W1–W12) are in Release reconciliation, and the plan of record is now End-to-end plan (phases P0–P9, client + backend, with the architect review). This table is kept as the record of what was planned, per the rule that a plan is superseded by a banner rather than an edit.
Nothing here starts until round 2 designs land. Everything above is complete by then, so this phase is composition against tested interfaces.
⚠ Round 1 changed one assumption here in our favour. The design's consumer tab bar already reserves a centre slot as "the single place new capability lands, so the bar never grows a fifth destination" — and My QR Setu is that capability. So 4.1 is writing the code for a bar that is already designed, not deciding a navigation model. Round 1 also confirmed no tabs inside My QR Setu: one scrolling surface, with the biodata card opening a detail surface carrying four views on one route.
Ordered to match round 1's own build sequence, which front-loads the vocabulary-fixing screens.
| # | Task | Round-1 screen | Est |
|---|---|---|---|
| 4.1 | Consumer _layout.tsx tab shell, My QR Setu on the centre slot — no tab bar exists in code today | — | 0.5d |
| 4.2 | Identity home: address + QR, activated card, Available (locked), settings | 1 | 0.75d |
| 4.3 | Biodata content editor with tier markers, owner-vs-subject aware copy | 2 | 1.5d |
| 4.4 | Photo upload + crop; basic-tier gated variant, full behind approval | — | 0.75d |
| 4.5 | People and access, with a request arriving and expiry states | 3 | 1.0d |
| 4.6 | Share sheet, link mint, lifecycle (open → in discussion → concluded) | — | 0.75d |
Phase 5 · Recipient web page
DOM/SSR in apps/web, following order-status.tsx and /:slug/setu-card as worked precedents.
| # | Task | Done when | Est |
|---|---|---|---|
| 5.1 | apps/web/src/tiers/consumer/ — the tier does not exist | Route renders server-side | 0.75d |
| 5.2 | Token-scoped biodata view, tier-aware | Only the granted tier renders | 0.75d |
| 5.3 | headers export — noindex, no-store, Cache-Tag | ⚠ A loader's headers do not reach a document response without this export. It cost the Setu Card its entire cache layer once already | 0.25d |
| 5.4 | OG preview: considered, and photo-free | Link preview in a real WhatsApp chat carries no photograph and no private field | 0.5d |
| 5.5 | Conversion CTAs + the closed-proposal page | CTAs point at /app or the APK, never a store URL | 0.5d |
| 5.6 | Report-abuse path | A viewer with no account can report a profile | 0.5d |
⚠ 5.4 has a permanence problem worth designing around: WhatsApp caches OG previews, so revocation does not reach a preview already fetched. Keeping photographs out of the preview is what makes that survivable.
Phase 6 · Ship
| # | Task | Done when | Est |
|---|---|---|---|
| 6.1 | en / mr / hi copy for every new leaf | i18n-catalogs.test.ts key-parity green. Marathi reviewed by a human, not machine-translated | 1.0d |
| 6.2 | Parity verification on all four surfaces | Android native, iOS native, /app, recipient web. No exception path exists | 0.75d |
| 6.3 | Signed APK + a download page | Real upload key, versionCode monotonic, served from qrsetu.com | 0.5d |
| 6.4 | Privacy policy, terms, grievance route | ⚠ QRS-320, launch-blocking legally. Sign-in copy already asserts agreement to documents that do not exist at any URL | 0.75d |
| 6.5 | Sentry DSN set; a track() sink on mobile | Errors and usage are visible. Both are currently no-ops | 0.5d |
Phase 7 · The cut list, decided in advance
Scoped work exceeds capacity. Deciding what goes now is cheaper than discovering it on 9 September.
| Order | Cut | Why it is safe | Cost of cutting |
|---|---|---|---|
| 1 | PIN / Face ID (3.5, −1.0d) | A consumer session holds no merchant data; the OTP is already per-device | A shared family phone is less protected. Recommended cut, week of 15 Sep |
| 2 | Per-recipient minted links (part of 4.5, −0.5d) | Two static tiers still deliver the core promise | Disclosure is per-tier, not per-person, until the next release |
| 3 | View analytics (−0.5d) | Not one of the three acute pains | The traceability story waits |
| 4 | Report-abuse UI (5.6) | ⚠ Do NOT cut. It is an Apple 1.2 requirement and a duty-of-care minimum | — |
⚠ The consumer slug step (3.4) is NOT a cut candidate despite two plans proposing it. It is 0.25d and it is the foundation of My QR Setu; removing it makes the identity a promise rather than a thing.
What this plan does not cover
- Store submission. Neither store opens in this window (decisions §3).
- Marketplace integration. Explicitly out of Day 1.
- Minimum age. DOB is a core field, age is published in the open tier, and no constraint exists anywhere. This needs a decision before real families use it, and it is not in any phase above because it is an owner decision rather than a task.