Skip to content

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.

#TaskWhereDone whenEst
0.1Fix production. Redeploy apps/web with Worker vars asserteddeploy:manual production, or deploy-web.yml dispatch after 1 Sepcurl qrsetu.com/<slug>/setu-card returns 404 not 500, and /merchant returns 2000.5d
0.2Generate a real Android upload keystoreapps/mobile/android/app/build.gradle, gradle.propertiesrelease no longer references signingConfigs.debug; keystore lives off the repo and off C:\; apk analyzer shows the new signer0.5d
0.3✅ MEASURED 2026-08-31. Auth path for the Google-only usersauth.identities on DevSee below. Severity corrected0.5d
0.4✅ RESOLVED 2026-08-31, no work neededpackages/data/src/auth/service.supabase.ts:69-80See 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.

#TaskDone whenEst
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-9860.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 name0.5d
1.5Widen media_scope_exactly_one to three-wayworkspace_id | conversation_id | owner_user_id, exactly one0.25d
1.6Wire the private R2 bucket_shared/r2.ts takes a bucket; manage-media stops hardcoding bucket: 'media'; a consumer photo lands in private0.75d
1.7biodata_subjects + marriage_biodatas + disclosure tiers + biodata_grantsTables, RLS, and the one-active-per-subject partial unique index1.0d
1.8feature_grants entry capping subjects at 1The owner's one-per-account rule holds, enforced by entitlement not schema0.25d
1.9pgTAP: a consumer reads nothing merchant-owned; an anon token read returns only its tiernpm run test:db green, negative assertions included0.75d
1.10D6 the profile number — sequence, Feistel permutation in the write path, bigint column, unique indexQRS 482 011 735 renders; the key is an EF secret, never in pg_proc; two adjacent rows have unrelated references0.5d
1.11D7 versioning — biodata_templates sibling table, retire guard, template_key/template_version on the recordA version cannot be retired while a row pins it; no FK from the record to the registry0.5d
1.12Structured person model — family_members jsonb, replacing the ·-separated stringThree family layouts and the vouching list read objects; nothing splits on a delimiter0.25d
1.13Emblem artwork as platform assets DELETED 2026-08-31. The emblems are commissioned SVG components that ship in the design bundle, so there is no asset path, no CDN and no provenance record to build. A user's own uploaded emblem is still user media and still goes through 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 ​

#TaskDone whenEst
2.1manage-biodata Edge Function (create, edit, publish, close, retire)Co-located Deno tests green: success, validation, authz, safe 500s1.0d
2.2Public read RPC for the recipient page, token-scopedReturns only the tier the token grants; SECURITY DEFINER, REVOKE ALL FROM PUBLIC, explicit GRANT0.5d
2.3Rate limit on the anonymous readA limit exists at some layer. There is currently none anywhere in the repo0.5d
2.4Subject notification via WhatsApp per D3One message on publish, with view + removal links; skipped when subject is the account holder0.5d
2.5packages/schemas Zod + packages/data seam with a real Supabase implBarrel binds the real service, not a stub. check:rpc green0.5d
2.6Cache purge on writeEditing a biodata invalidates the recipient page. Reuse invalidateCardCache's pattern0.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.

#TaskDone whenEst
3.0✅ DONE. Meta account, production WABA, registered Cloud API number, business profile and brandingcan_send_message: AVAILABLE on WABA, BUSINESS and APP; number CLOUD_API + CONNECTED + VERIFIED—
3.2✅ DONE. Authentication templateqrsetu_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.1Supabase Send SMS Hook Edge Function delivering via Meta Cloud APIA real code arrives through the hook rather than a script. Never Supabase's native channel:'whatsapp', which is Twilio-only1.0d
3.1aWebhook receiver in the same function, subscribed to the WABADelivery receipts and failure codes reach us. Until this exists a failed send is indistinguishable from a successful one, which cost most of a day already0.5d
3.1bAlign the two expiriesSupabase 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 refused0.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.3Phone OTP path in the apps/mobile auth featureSign-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 celebrate0.5d
3.3aRate 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 gap0.5d
3.5PIN / Face ID gate + recoveryexpo-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.

#TaskRound-1 screenEst
4.1Consumer _layout.tsx tab shell, My QR Setu on the centre slot — no tab bar exists in code today—0.5d
4.2Identity home: address + QR, activated card, Available (locked), settings10.75d
4.3Biodata content editor with tier markers, owner-vs-subject aware copy21.5d
4.4Photo upload + crop; basic-tier gated variant, full behind approval—0.75d
4.5People and access, with a request arriving and expiry states31.0d
4.6Share 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.

#TaskDone whenEst
5.1apps/web/src/tiers/consumer/ — the tier does not existRoute renders server-side0.75d
5.2Token-scoped biodata view, tier-awareOnly the granted tier renders0.75d
5.3headers 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 already0.25d
5.4OG preview: considered, and photo-freeLink preview in a real WhatsApp chat carries no photograph and no private field0.5d
5.5Conversion CTAs + the closed-proposal pageCTAs point at /app or the APK, never a store URL0.5d
5.6Report-abuse pathA viewer with no account can report a profile0.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 ​

#TaskDone whenEst
6.1en / mr / hi copy for every new leafi18n-catalogs.test.ts key-parity green. Marathi reviewed by a human, not machine-translated1.0d
6.2Parity verification on all four surfacesAndroid native, iOS native, /app, recipient web. No exception path exists0.75d
6.3Signed APK + a download pageReal upload key, versionCode monotonic, served from qrsetu.com0.5d
6.4Privacy policy, terms, grievance route⚠ QRS-320, launch-blocking legally. Sign-in copy already asserts agreement to documents that do not exist at any URL0.75d
6.5Sentry DSN set; a track() sink on mobileErrors and usage are visible. Both are currently no-ops0.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.

OrderCutWhy it is safeCost of cutting
1PIN / Face ID (3.5, −1.0d)A consumer session holds no merchant data; the OTP is already per-deviceA shared family phone is less protected. Recommended cut, week of 15 Sep
2Per-recipient minted links (part of 4.5, −0.5d)Two static tiers still deliver the core promiseDisclosure is per-tier, not per-person, until the next release
3View analytics (−0.5d)Not one of the three acute painsThe traceability story waits
4Report-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.