Appearance
Marriage Biodata — architecture assessment of the built design
⚠ ASSESSED AT A POINT IN TIME. ROUNDS 7, 8 AND 9 POST-DATE IT.
This assessment covers rounds 3 to 6. Since then: round 7 added the kundli group plus religion, caste, mama and soyare; round 8 added the opening block; round 9 is the language pass, the structured person model and the artwork seed. Its findings A1 (photo gating is production-only), A2 (no unpublish) and A3 (the slugs dependency) all still stand. Its field-level detail does not.
An assessment is a log: it records what was true when it was run. Do not edit it to match a later round; run a new one.
Assessed 2026-08-30 against the design project fetched live (not cached) and the repo's live migrations, Edge Functions and data seams. Covers rounds 3 to 6: the in-app screens, the public recipient page, biodata-core.js, biodata-family.js, and every edit the design made to consumer-data.js, qr-registry.js and open-in-app.js.
Verdict in one line
The integration is sound and most of the feature is buildable on what exists. Three findings change the plan: one is a security control that is inert outside production, one is a missing state the design never had, and one is a dependency on a table that does not exist.
A · The three findings that change the plan
A1 · ⚠⚠ The photo-privacy control does not work on Dev or UAT
The whole photograph story rests on serving a small cropped variant at the basic tier so a leaked image is low-value. That is a Cloudflare Images transform, and packages/data/src/mediaUrl.ts:39-55 records the measurement (QRS-808, 2026-08-22):
"Cloudflare Images Transformations are served by a ZONE.
media.qrsetu.comreturns 200 for a transform and the returned PNG really is resized;pub-<id>.r2.devreturns 404 for the identical request… which means Dev and UAT must be served the DIRECT object url."
apps/mobile/.env.development points at pub-*.r2.dev. So on Dev and UAT originSupportsTransforms() is false and the full-resolution original is served. The gating is production-only.
Consequence, and it is not theoretical: any pre-production pilot with real families publishes full-resolution photographs of young women with no gating at all — the exact exposure the feature exists to prevent. Do not run a pilot on Dev. Either provision a zone-attached media origin for UAT, or accept that photo testing happens only in production, deliberately and with test images.
A2 · There is no UNPUBLISH
CREATION_STATUS is live · draft · ended, and the design maps concluding to ended. That is correct and matches the sub-state rule. But ended is a terminal, celebrated state that renders a concluded page forever — it is not "take this offline for a week."
A creator who wants to pause — a family emergency, a bad recipient, a photo they regret — has only two options today: conclude (wrong, and public) or delete. Neither is reversible.
Recommended: add
pausedtoCREATION_STATUS. It is one value in an existing check constraint, it reuses the existing chip, and it is the smallest honest answer. The public page renders the same "not available" state the withdrawn link already has.
A3 · The feature depends on the slugs table, which does not exist
The design's own flag 1 is honest about it: "The identity slug still resolves as a Setu Card until the owner-kind lookup is wired. setOwnerKindLookup() exists and both matchers ask it; with no lookup installed it returns null and behaviour is unchanged."
That is the right way to ship a dependency. But it means /<slug> opening a person's identity is gated on D1, which is designed and unbuilt — and the enumeration hazard D5 was written about is unresolved until it lands. See QRS-914.
B · Feasibility, feature by feature
A fully supported · B minor extension · C meaningful expansion · D reconsider
| Feature | Class | Assessment |
|---|---|---|
| Identity home, three existing entry points | A | HOME_SECTIONS.yourCards, CONSUMER_MORE_TOOLS.create, ACCOUNT_GROUPS all exist. No chrome change. |
| Biodata record: slug, status, lifecycle | B | New table. Shape is the setu_cards pattern with a user owner. |
| Standard fields with a tier each | B | Typed columns + a tier map. Projection by tier is an RPC concern. |
| Custom fields (6 types, 5 presentations) | B | ⚠ Looks like C and is B, because the precedent exists — see C2. |
| Family layouts (tree · vine · cards · list) | A | Pure client SVG. One family_layout column. Zero backend. |
| The person model behind the tree | B | ⚠ siblings became ·-separated text parsed into people. That is a schema decision wearing a string — see D1. |
| Photo carousel + full-screen viewer | A | Client. media already supports multiple rows and ordering. |
| Photo tier gating | C | Not the UI — the serving path. See A1 and E2. |
| Per-recipient shares, expiry, withdrawal | B | New table. Token generation and expiry are ordinary. |
| Access requests | B | New table, same shape. |
Representative ("who to talk to", repPhone) | D | A real phone number at released tier, with no rate limit anywhere. See F3. |
| Address / map | A | mapUrl() builds a link; a still, not an embed. Correct call on a noindex page. |
| LinkedIn pill | A | One optional released field. |
| Subject removal without owner veto | B | Needs an authenticated-by-token path for a non-account subject. |
| Concluded page as acquisition surface | A | Public route, no auth. |
| WhatsApp link preview, photo-free | B | Needs an OG image route. MEDIA_VARIANTS.og exists. |
| In-app celebration | A | Inside Biodata.dc.html, correctly not a screen. |
| Proactive nudges via the Today strip | A | Reuses the existing row. No new component. |
| Real-time notification of a request | C | ⚠ There is no push channel at all. See E4. |
| Analytics ("who opened, when") | C | No event stream exists for this. See E5. |
| PDF export | D | Deferred by the spec. Agree — see H2. |
C · What the existing architecture already gives us
C1 · Media is further along than expected
media is already consumer-ready: workspace_id was made nullable on 2026-08-14, and media_scope_exactly_one is a two-way XOR. bucket already carries check (bucket in ('media','private')), so private storage is modelled in the column. manage-media implements issue_upload · confirm · delete — a presigned upload flow — and _shared/r2.ts presigns with expiresInSeconds.
What is missing is wiring, not schema: _shared/r2.ts reads a single R2_BUCKET and manage-media/index.ts:247 hardcodes bucket: 'media'. Widening the XOR to three-way and threading a bucket is roughly a day.
C2 · Custom fields have a precedent, and a governing rule
Do not invent an EAV table. industries.item_attribute_schema jsonb already solves exactly this shape — a per-context field schema over a jsonb value bag — and its own column comment states the rule that must govern biodata too:
"THE SPLIT RULE IS UNCHANGED (ADR-0010): anything the platform FILTERS, SORTS, GROUPS, CHARTS, PRICES or GATES on is a typed column, never a key in here. Height belongs here; price never could."
Applied: every field the page's structure depends on — name, age, city, education, profession, the tier of a standard field — is a typed column. Custom fields are custom_schema jsonb + custom_values jsonb. MAX_CUSTOM = 8 becomes a feature_grants limit, exactly as the design already calls it "a plan setting, not structure".
⚠ One caveat the split rule forces: a custom field's tier lives in the schema jsonb, and the public RPC must read it to decide what to project. That is projection, not filtering, so it stays inside the rule — but it means the RPC is the only safe place tier resolution can happen. A client-side tier filter would be a disclosure bug.
C3 · Everything else that is reused, not rebuilt
ds-base.js · icons.js · image-slot.js and its 4:5 contract · qr-toast.js · confirm-delete.js · the phone frame and tab bar · the registration sheet · the Today row · the notification list · Account.dc.html's ?view= sub-view pattern · open-in-app.js · card-composition.js. The design's reuse list holds up.
D · Where the design made a schema decision without saying so
D1 · ⚠ siblings as ·-separated text is a data model in a string
Round 5 changed siblings from a sentence to "· separated people… A person is the text before the first comma; the rest is what the family wrote about them." Three layouts and the vouching list all parse it.
That is a typed structure encoded as a delimited string, and it is the cheapest kind of technical debt to avoid and the most expensive to unpick. It breaks the moment a name contains a comma, it cannot be validated, it cannot be indexed, and every consumer re-implements the parse.
Recommended:
family_members jsonb— an array of{ relation, name, detail, order }. Same render, same three layouts, no parser, and it is the same jsonb-with-schema shape C2 establishes. The editor writes objects; nothing splits on·.
D2 · Multiple photographs need explicit ordering
The carousel implies order. media has no sort_order. Add one, or store an ordered array of media ids on the biodata row. The second is simpler and matches how the card already composes.
E · Backend, and what genuinely has to be built
| # | Work | Class | Note |
|---|---|---|---|
| E1 | marriage_biodatas, biodata_subjects, biodata_shares, biodata_access_requests + RLS | B | Ordinary tables. Follow the orders/conversations RLS idiom. |
| E2 | Private bucket wiring + three-way media XOR | B | ~1 day. Schema exists; the code hardcodes one bucket. |
| E3 | manage-biodata Edge Function | B | Follow manage-item. Co-located Deno tests. |
| E4 | Push notifications | C | ⚠ Nothing exists. No token table, no sender, and expo-notifications is used for local scheduling only. An access request that waits four days is the loss the design names. |
| E5 | A view/access event stream | C | Analytics ("who opened, when") has no substrate. ⚠ Do not reuse scans — the design deliberately removed scan counts from the biodata record, and rebuilding them as an event table would reintroduce the merchant framing it rejected. |
| E6 | Rate limiting on the public read | C | ⚠ grep rate_limit over all migrations returns nothing. The public biodata read is anonymous, token-addressed and unthrottled. |
| E7 | Slug registry (slugs, owner_kind) | C | D1. The identity route depends on it. |
| E8 | Subject removal by token, no owner veto | B | A signed link, an EF, an audit row. |
| E9 | OG image route for the preview | B | MEDIA_VARIANTS.og exists; the route does not. |
F · Security and privacy
- A1 is the headline. The primary photo control is production-only.
- The public read is unauthenticated and unthrottled (E6). A token is a bearer credential; with no rate limit, token guessing and scraping are both unbounded. This should gate the launch, not follow it.
- ⚠
repPhoneat released tier is the sharpest disclosure decision in the feature. A real phone number, released to a recipient the family approved — reasonable — but once seen it cannot be unseen, and withdrawal does not recall it. The design is right that a number is a release rather than a publication; what is missing is that the UI must say so plainly at the moment of release. - Enumeration remains open until E7 lands. The design flags it honestly.
privatefields must be unreachable by any path, not merely unrendered.publishedCustom()is the right shape; the equivalent must exist server-side for standard fields, in the RPC.- Screenshots cannot be prevented on mobile web and the spec does not claim otherwise. Copy must never imply they can — QRS-549.
G · Controls and states — the audit
| Control | Supported? |
|---|---|
| Create · Edit · Save · Draft · Preview · Publish | ✅ designed |
| Unpublish | ❌ missing — see A2 |
| Delete | ✅ |
| Restore after delete | ❌ not designed. Soft delete exists in users.status but not for creations |
| Validation · empty · error · partial · unsaved | ✅ enumerated in the spec's states table |
| Concurrent updates | ❌ no optimistic concurrency anywhere in the platform. Two family members editing will last-write-win |
| Media handling | ⚠ see A1, D2 |
| Permission / authorization | ✅ by owner; ⚠ subject-side needs E8 |
| Public/private separation | ⚠ correct in design, must be enforced in the RPC (C2) |
| Optional fields render only when provided | ✅ publishedCustom(), and address/map handles all four combinations |
H · Pushback — what to simplify or drop
H1 · The four unplanned artifacts. BiodataView.dc.html, biodata-family.js, BiodataAudit.dc.html and family-avatars.brief.md were not in the integration map. Three are defensible (a view screen, a family module, a design audit). family-avatars.brief.md is a brief for work nobody asked for. Confirm each earns its place before implementation.
H2 · PDF export — agree with deferring, and go further. The spec keeps it as a "polite answer". It is a second renderer of the same data, and ADR-0019's one-renderer rule exists precisely because a second renderer drifts. If it ships, it must be generated from the same public page, never authored separately.
H3 · Custom fields are at the edge of justified. Six types × five presentations × eight suggestions × conflict detection is a lot for R1. The guards are well designed and the precedent exists, so this is not over-engineering in shape — but it is in timing. Recommend: ship the intro free-text field and the three lead chips in R1, defer the typed custom-field system. Those two carry most of the value the round-4 rationale claims.
H4 · The family tree is cheap and should stay. Pure client SVG, one column, four layouts with automatic degradation. It is the least risky thing in rounds 4 to 6 and probably the most loved.
I · Sequence and dependencies
E7 slugs registry ──┬─→ identity route, enumeration closed
E2 private bucket ──┴─→ E1 tables + RLS ─→ E3 manage-biodata ─→ app screens
└─→ public RPC (tier projection) ─→ public page ─→ E9 OG
E6 rate limit ────────────────────────────────────────────────→ BEFORE any public launch
E4 push ─── deferrable to post-launch; in-app + WhatsApp cover the two urgent events
E5 analytics ─── deferrable; the feature works without it, the retention story does notBlocking before a real family uses this: E1, E2, E3, E6, and A2's paused state. Blocking before the identity address is printed on anything: E7. Deferrable with a stated cost: E4, E5, H3's custom-field system.