Appearance
My QR Setu — the Consumer identity layer
🔵 Proposed. Not built. No ADR. This is the consumer's permanent digital identity, and the layer every consumer feature attaches to.
The concept
qrsetu.com/<consumer-slug>/
My QR Setu
│
┌──────────┼──────────┬──────────────┐
│ │ │ │
Contact Social Website Marriage Biodata
│
(its own dedicated
shareable destination)The slug belongs to the Consumer, not to any one feature. A consumer activates experiences into their identity; each activated experience may have its own destination, analytics, privacy posture and lifecycle, while the identity itself persists.
⚠ The QR Tools hub in the screenshot does not exist in the current product
This needs saying plainly, because the concept is right and the evidence for it is not where it looks.
Measured 2026-08-28:
- The merchant
qr-toolsfeature is three files — a README, a barrel, and one screen. It offers exactly one QR type: the merchant's own Setu Card URL (QrToolsScreen/index.tsx:156-157, 221). - The multi-tile hub was deliberately DELETED by the approved design, and the screen's own header records the deletion as its central decision.
- There is no
qr_codestable in any live migration. It exists only in the archived pre-v2 baseline, where it was the dynamic multi-type model (qr_type,content,color,size). - QR generation is client-side only, with no stored record. The destination is static and structurally immutable: a hardcoded origin plus a write-once slug.
- There is no scan tracking of any kind. No scan or view event table exists.
- ⚠ The vocabulary in the screenshot — contact card, bio-links, social, resume, portfolio, business profile, catalogue, coupons, Google Review, payment, WhatsApp, app download, website, documents — matches the RETIRED legacy SPA's 15-type marketing generator (
legacy/src/tiers/landing/lib/constants.js:74,QR_TYPES_LIST), not anything currently built.
Why this matters rather than being a detail: the merchant side is not a precedent to copy, it is a thing that would have to be built too. Treating the screenshot as existing capability would put "consumer analogue of the QR hub" in the reuse column when it belongs in the new-architecture column, and that is a scoping error of several weeks.
⚠ Note also the copy trap: the merchant screen calls its code "dynamic", and means the card content behind a fixed address is editable — not that the QR destination can be re-pointed. A "re-pointable QR" is a different capability and does not exist.
✅ But a consumer QR hub concept IS already built, in the domain layer
The better precedent is on the consumer side, and it is real:
packages/domain/src/consumer/home.ts:74-90
CONSUMER_QR_TOOLS = ['scan', 'my_order_code', 'recently_scanned', 'share_contact']- It is surfaced as an always-present row on the consumer home — a section-presence decision, not an entitlement gate (
home.ts:59-61). - The domain layer already records an explicit decision not to carry merchant QR tools across to the consumer: no payment QR, no WhatsApp, no catalogue (
home.ts:70-72).
So the consumer QR hub is not a new idea here — it exists, it is opinionated, and My QR Setu should extend it rather than invent a parallel concept.
⚠ Two of its four entries point at /consumer/scan, which does not exist — no route file, no catch-all. That is a live dead link, and it violates the repo's own rule stated in the sibling merchant registry: "AN ENTRY WHOSE DESTINATION DOES NOT EXIST IS ABSENT, NOT DISABLED." It is the same screen the unbuilt ScanVerify was to provide.
✅ Where the existing architecture already agrees with you
This is not a new idea the architecture has to be bent to accommodate. apps/web/src/app/routes.ts says it outright, about the vendor side:
"
/<slug>IS A NAMESPACE ROOT, NOT A PAGE. Everything a vendor owns hangs off it as a sibling — the card, the order route, the confirmation, and item listings when the marketplace ships. That is why/<slug>itself is a 301 rather than a second copy of the card: it stays free to become a real vendor hub later."
So the platform already treats /<slug> as an identity root that hangs experiences off it as siblings, and deliberately kept it free rather than spending it on a page. Your requirement — reserve /<slug>/ for the consumer's identity, give the biodata its own sibling destination — is the same decision applied to the consumer side.
Classification: this is not an existing-architecture limitation. It is an existing-architecture intent that has not yet been extended to consumers.
⚠ The actual data-model problem, measured
Here is where it genuinely breaks, and this is a data-model problem, not terminology and not a future-scalability worry.
The slug is not owned by an identity. It is a column on the vendor card table.
sql
-- supabase/migrations/20260808160000_v2_cards.sql:41-47
create table public.setu_cards (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null unique references public.workspaces (id) on delete cascade,
slug citext not null unique
check (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$' and slug !~ '--'),
...Three consequences follow directly, and none is a matter of opinion:
- A slug structurally requires a workspace.
workspace_idisnot null. A Consumer has zero workspace memberships by definition, so a consumer cannot hold a slug today at all. - Uniqueness is a single-column
UNIQUEon one table. If consumer identities get slugs from a different table, nothing preventsrahul-sharmaexisting as both a vendor card and a consumer identity. A per-table constraint cannot express cross-table uniqueness. - The slug is write-once by trigger (
setu_cards_slug_write_once) andreserved_slugscarries ~2,500 entries guarding it (753 seeded in the baseline, the rest added by the governance migration). Both mechanisms are attached to the card table, not to a namespace. ⚠ An earlier draft of this page said 753 here and ~2,500 twelve lines later — the same self-contradiction this repo keeps catching in prose counts.
So /<slug> today is a business namespace wearing a generic name.
The decision this forces, and my recommendation
The namespace is flat and global: one string space shared by everything addressable at the root. Adding consumers to it is a real decision with three shapes:
| Option | Shape | Assessment |
|---|---|---|
| A. Give consumers a workspace | reuse setu_cards | ❌ Reject. It contradicts the defining property of Category 3 ("zero workspace memberships"), makes users.primary_context meaningless, and drags seat licensing and org policy onto a person who has no business. It solves a naming problem by corrupting the tenancy model. |
| B. Separate consumer-identity table with its own slug column | two tables, two UNIQUEs | ❌ Reject. Cannot enforce cross-table uniqueness. Two people can claim the same public address, and you find out at render time. |
| C. Promote the slug to a shared registry | slugs(slug PK, owner_type, owner_id); setu_cards and consumer identity both reference it | ✅ Recommend. |
Why C, concretely
- Global uniqueness becomes structural — one primary key over one string space, which is exactly what a flat root namespace is.
reserved_slugskeeps working unchanged, and starts protecting consumers too.- Write-once moves to the registry, where it belongs — the guarantee is about the URL on paper, not about a card row.
/<slug>becomes genuinely resolvable: look up the owner type, then render a vendor hub or a My QR Setu. That is the namespace-root intent the routes file already describes, finally expressible.- Future card products inherit it rather than each inventing slug ownership.
⚠ This is a migration on a greenfield Dev database, which CLAUDE.md's second rule explicitly sanctions — "You have standing authority to redesign any backend artifact from scratch… Dropping and rebuilding on Dev is sanctioned; it is the expected mechanism, not an escalation." Doing it now costs a migration. Doing it after real slugs are printed on QR codes is, in the routes file's own words, "immutable in the strongest sense the product has."
This is the most time-sensitive decision in the section — not because anything is broken, but because a slug is write-once and ends up on paper, so deciding it late is the one cost here that cannot be refunded.
What My QR Setu is NOT
- Not a Setu Card. A Setu Card is a business's public presence, indexed and cached for reach. My QR Setu is a person's identity, and per the PII rule it is never indexed. Same URL shape, opposite posture.
- Not a link-in-bio clone. The differentiator is that activated experiences carry their own privacy, lifecycle and analytics — not that they are a list of links.
- Not required in order to ship the first feature. A marriage biodata could ship with its own destination and no identity layer. ⚠ But the slug decision cannot wait, because whatever the first consumer feature claims as a URL is the thing that becomes immutable.
✅ Owner decision (2026-08-28) — the slug is claimed at onboarding
"Consumer slug is opted during the onboarding, default with the name entered, but just like the merchant the consumer has feasibility to edit during onboarding, hence default name to be picked."
So: a slug step in consumer onboarding, pre-filled by slugifying the name, editable before it is committed. Exactly the merchant behaviour.
🔎 The codebase already anticipated this, and recorded it as a deferral rather than a gap
useOnboardingFlow.ts:11-23 — the individual step list carries this comment verbatim:
"⚠ NO
slugSTEP, AND THAT IS A DEFERRAL RECORDED RATHER THAN A FEATURE DROPPED (QRS-730). A slug is a PUBLIC IDENTITY: it mintsqrsetu.com/<slug>, is printed on a QR code, and is immutable once distributed. The owner's consumer vision does include a personal Setu Card and slug as a digital identity layer, but that needs its own decisions — reservation, uniqueness against the merchant namespace, what the page renders, and whether a consumer's card is indexable — none of which exist yet."
All four of those decisions now have answers, three from the owner and one recommended here:
| The code's open decision | Answer |
|---|---|
| Reservation | Reuse reserved_slugs (~2,500 entries), extended to cover consumers |
| Uniqueness against the merchant namespace | ⚠ The unsolved one. See the shared registry below |
| What the page renders | My QR Setu — the identity hub, with activated experiences |
| Whether a consumer's card is indexable | ⛔ No. Never. Consumer data is PII and is never indexed |
🟩 Most of the UI already exists
SlugStep.tsx was written for both cases from the start:
js
// SlugStep.tsx:89-92 — "Auto-suggest a handle from the brand (business) or name (individual)"
const seed = slugify(draft.brand_name ?? draft.full_name ?? '');It already falls back to full_name for an individual, already slugifies typed input, and already debounces a remote availability check. And resolve_setu_card_slug_status(text) is granted to authenticated — which a consumer is.
So the client work is close to "add
'slug'toINDIVIDUAL_STEPS". The work is not in the UI. It is that a consumer slug has nowhere to live yet, because the slug is a column onsetu_cards, which is workspace-keyed — so the extension is to move slug ownership up into a shared registry, which is recommended in full above.
The objection the code raises, and why it does not block this
The same comment argues: "Asking a buyer to name a public URL before they can browse is also a wall in front of browsing, which CLAUDE.md's anonymous-first rule calls non-negotiable."
That objection is answerable, and the answer matters. Anonymous-first governs browsing, which requires no account at all. A person who only wants to look never onboards. Onboarding happens when someone chooses to create something — and at that moment naming their address is appropriate rather than obstructive.
⚠ The rule to preserve: never put onboarding in front of viewing. A slug step inside onboarding is fine; onboarding as a precondition for opening a shared link is not.
⚠ The consequence this decision creates, stated so it is a knowing choice
Consumers will be the largest population on the platform by orders of magnitude (CLAUDE.md's own threat-model note). If every consumer claims a slug at onboarding, then the largest population consumes the same flat namespace merchants draw from. rahul-sharma the consumer permanently blocks rahul-sharma the future caterer, and slugs are write-once.
Three shapes, and the owner's instruction selects the first:
| Shape | Note | |
|---|---|---|
| A. One shared namespace, first-come | ✅ selected — "just like the merchant" | Simple and predictable. Only safe with the shared registry below, because uniqueness must be enforced across both owner types. |
| B. Consumers claim a slug only on first publish | Preserves namespace for commercial use | Contradicts "at onboarding" |
| C. Separate namespaces by prefix | No contention | Contradicts "just like the merchant" |
One upside of A worth naming: a consumer who later starts a business keeps their slug. Under a shared registry that is a change of owner_type, not a new address — which is exactly CLAUDE.md's rule that gaining a business must be "never a second account, never a data migration." Option C would force precisely the migration that rule forbids.
✅ Owner decisions (2026-08-28) — identity, addressing and lifetime
1. What /<slug> renders
"Digital identity with all enabled features that are meant to be public and that he wants to share with the world. Default is his digital identity with public profile data, and this will be leveraged further for an advanced link-in-bio."
So /<slug> is never empty and never a 404. It renders the consumer's digital identity: public profile data by default, plus any activated experience the consumer has chosen to surface publicly. The consumer decides what appears.
Two consequences worth stating:
- The identity page is intended-public — that is its purpose. It is still never indexed (see 3). "Public" here means anyone I give the address to, not discoverable by strangers.
- Activation and publication are two different things. A consumer can have the marriage biodata activated without it appearing on their identity page. That distinction is what makes the next answer workable.
2. Why an enumerable feature URL is a problem — the question you asked
You asked where this actually bites. Concretely:
Slugs are derived from names, so they are guessable. That is by design — SlugStep seeds the slug by slugifying the name. So if a biodata lives at a predictable path off the slug:
qrsetu.com/priya-sharma/biodata
qrsetu.com/amit-patel/biodata
qrsetu.com/sneha-kulkarni/biodata ← a script can iterate common Marathi names…then anyone can harvest biodata in bulk without ever being given a link. Each successful hit returns photographs, age, city, education and profession of (most often) a young woman. That is a scraping vector, and it is the specific harm the whole privacy design exists to prevent.
It contradicts your own rule. You wrote that a biodata is "intended to be viewed by the person who has that shared link." An enumerable URL means it is viewable by anyone who can guess a name, which is not the same thing at all.
An unguessable token closes it:
qrsetu.com/b/7f3a91c4e8d24b6f ← cannot be iterated; reachable only if someone sent it to youThat is exactly the access model you described, expressed in the URL itself.
Where each applies:
| Surface | Address | Why |
|---|---|---|
| Identity (My QR Setu) | vanity slug — /<slug> | Intended-public, chosen by the consumer, meant to be spoken and printed |
| Feature destination (biodata) | unguessable token | Personal, shared deliberately with named people, must not be discoverable by guessing |
⚠ And the two interact. If the biodata is linked from the public identity page, then anyone who reaches the identity can follow it — which re-opens the hole. Your answer to (1) resolves this: the consumer chooses what appears on their identity, so a biodata can be activated and deliberately not surfaced there. That choice must be the default for a biodata, not an option the consumer has to find.
The platform already has this exact precedent: order-status.tsx is reachable only by holding a URL that carries two secrets, and its header calls the URL "a bearer token".
3. Indexing — settled
"Consumer slug is not meant to be publicly indexed, so avoid indexing and avoid making it objectionable for PII on the internet, which could be harmful and a security and privacy compliance case."
⛔ noindex, on the identity page and on every feature destination. Set in headers(), not only in meta() — a crawler that never runs JS still reads the header. Same posture as order-status.tsx, and per the PII rule it is a caching rule as much as an indexing one.
On the renderer half of that question, which the answer did not cover — my recommendation: the two hubs should not share a renderer, and ADR-0019 does not require them to. ADR-0019 fixes one renderer for the Setu Card specifically; a consumer identity hub is a different page with the opposite indexing and caching posture. They should share the token and component layer and be separate route modules with different headers(), because the headers are the thing that must not be inherited by accident.
4. Slug lifetime — never transferable
"No slug to be assigned to another. It should be the same consumer identity for their lifetime, soft deleted, and later once the user activates their account with the same email/mobile it should be aligned with them again. But once claimed, a slug should not be transferable to anybody."
A clean rule, and the schema already half-supports it:
- ✅
public.users.statusalready allows'deleted':check (status in ('active', 'suspended', 'deleted'))— soft delete is already expressible. - ⚠ But the code does the opposite.
manage-accountcallsserviceClient.auth.admin.deleteUser(user.id)(index.ts:113), andpublic.users.idisreferences auth.users (id) **on delete cascade**. Today's deletion is a HARD delete that destroys the row, so the slug link and the ability to realign on return are both lost. Classification: ⬜ defect against this decision, not new architecture — the status value exists and is unused by the delete path.
⚠ The collision this creates with DPDP erasure, and how to resolve it
"Keep the email/mobile forever so we can realign the slug" and "erase my personal data" cannot both be honoured for the same request. Retaining an identifier after an erasure request is retaining personal data.
The resolution is to separate two things that look alike:
| Account closure | Erasure request (DPDP right) | |
|---|---|---|
| Trigger | "I'm done for now" | "Delete my data" |
| Personal data | retained, hidden | destroyed |
users.status | 'deleted' (soft) | row removed |
| Slug | retained, realigns on return with the same email/mobile | tombstoned — recorded as permanently retired, with no personal data attached |
| Reissued to anyone else | ❌ never | ❌ never |
A tombstone satisfies both of your constraints at once: the slug is never transferable to anybody, and nothing personal is retained. What is lost in the erasure case is only the ability to realign on return, which is correct — an erased user is starting fresh by their own request.
⚠ This makes the shared slug registry load-bearing rather than merely tidy. The tombstone has to live somewhere that is not a user row, because the user row is exactly what erasure removes. A slugs(slug PK, owner_type, owner_id NULL, retired_at) registry is the natural home, and it is the same structure already recommended for cross-namespace uniqueness.
🔵 One consequence to accept knowingly: if slugs are never released, the namespace only ever shrinks, and consumers are the largest population on the platform. That is almost certainly fine — strings are cheap and the check constraint allows 2 to 63 characters — but it should be a decision rather than a discovery.
Remaining open questions
- Does the identity page show anything before the consumer publishes anything? Answer (1) says public profile data by default. What that contains — name, photo, city? — is a design and a privacy decision, since it is the thing a stranger sees if they guess a slug.
- Can a consumer change their mind and un-publish the whole identity, leaving the slug claimed but nothing rendered?