Skip to content

Architecture ​

🔵 Research. No ADR, no schema, no decision taken.

Every claim on this page was measured on 2026-08-28 against live migrations and source. Archived migrations (_archive_pre_v2/, _archive_pre_baseline/) and legacy/ are not evidence.

How each concern is classified ​

Per the owner's request, every item says which kind of problem it is:

Meaning
✅ ReuseWorks today for a consumer, as-is.
🔧 ExtendExists for merchants; widen it. A recommended shape is given.
🆕 BuildDoes not exist, because the use case did not exist. A recommended design is given.
🩹 FixGenuinely broken today, independent of this feature.
🟨 TerminologyThe model is right; the words are confusing.
🔮 LaterDeliberately deferred; cheap now to keep possible.

⚠ How to read this page. Everything here is a feature that does not exist yet, so "the platform does not support X" is the expected starting condition, not a finding. What matters is the recommended extension, and every 🔧 and 🆕 below carries one. A gap named without a proposed shape is half a job.


1. Onboarding — ✅ you are right, reuse it ​

The owner's position: "I am not convinced there is an onboarding architecture problem… Does the existing onboarding implementation already create the correct account, profile, permissions and lifecycle required for a Consumer?"

Measured answer: yes, on mobile. The existing individual path already produces a correct Consumer.

FactEvidence
There is no account_type column. It is public.users.primary_context, text not null default 'business', CHECK-constrained20260808100000_v2_identity_and_tenancy.sql:76-77
It is a CHECK on text, not an enum, deliberately — an enum value cannot be removedsame file, :57-59
handle_new_user() provisions exactly one thing for any new auth user: a public.users row. No workspace, no card20260808230000_v2_auth_provisioning.sql:73-96
The individual path calls setPrimaryContext('individual') and explicitly does not call provisionWorkspaceOnboardingSetup/index.tsx:112
Individual onboarding is 3 steps (auth, name, celebrate) vs 6 for businessuseOnboardingFlow.ts:11
hasCompletedOnboarding short-circuits true for an individual — complete the moment the user row existspackages/domain/src/auth/onboarding.ts:65-68
get_my_context() returns workspaces: [] — a valid answer, not an error — and is granted to authenticated20260822141000_v2_context_projects_location.sql:47

Recommendation: do not design new onboarding. The account, the profile row, the zero-workspace lifecycle and the entry routing already exist and already work. This was the right challenge.

But four real gaps sit behind it, and none is an architecture problem:

GapClass
On web, the individual path writes nothing server-side — publish() returns early and setPrimaryContext has zero web callers (OnboardingScreen.tsx:246-249)🩹 Fix
resolveMerchantEntryRoute on web has no consumer branch — it reads only workspaces.length and ignores primary_context (entryRoute.ts:46-49)🩹 Fix
apps/web has no consumer tier at all — its tiers are admin, landing, merchant, public🆕 Build
The signup-metadata path that would stamp primary_context is dead: the only production caller passes no context, so every new user defaults to 'business' until something calls setPrimaryContext🩹 Fix, and the most consequential of the four

⚠ That last one matters for a consumer-first feature: a person who arrives to make a biodata is, at the database level, a business until an app screen corrects it.

2. "Individual" vs "Consumer" — 🟨 terminology, with a real but small cost ​

The owner suggests renaming. This is not UI-only: 'individual' is a stored CHECK-constraint value, so a rename touches the constraint, existing rows and every call site.

It is cheaper than it looks, for one measured reason: primary_context is a preference, never an authorization input — it appears in zero RLS policies, USING clauses or WITH CHECK clauses. Nothing depends on its value for access control.

Recommendation: rename now, or never. Dev is greenfield and CLAUDE.md's second rule explicitly sanctions rebuilding backend artifacts there. The value is not load-bearing for authorization, so the migration is mechanical. Doing it after real consumer rows exist means a backfill under load.

⚠ If the rename is rejected, change the UI label only and leave the stored value alone. Do not introduce a third word.

3. The slug — 🔧 extend ownership out of the card table ​

Full argument in My QR Setu. In short:

  • setu_cards.workspace_id is not null unique, and the slug is a column on that table (20260808160000_v2_cards.sql:41-47). A consumer with zero workspaces cannot hold a slug today.
  • Uniqueness is a single-column UNIQUE on one table, so a second slug-owning table cannot enforce cross-table uniqueness.
  • Recommendation: promote the slug to a shared registry (slugs(slug PK, owner_type, owner_id)).

⚠ This is the most time-sensitive decision in the section, because a slug is write-once by trigger and ends up on paper.

4. Owner vs subject, and one-per-account — 🔧 extend, with precedent ​

The owner's rule: one account may hold one active Marriage Biodata; the account owner is the authorization anchor; the biodata may be about a family member.

This is a clean model and the schema already has both halves of the pattern:

  • One-per-owner has direct precedent. setu_cards.workspace_id is not null unique — exactly one card per owner, enforced by a column constraint rather than by application logic.
  • Owner ≠ subject has precedent too: orders carries a nullable buyer reference precisely so a row can be about a party who may or may not be the account holder.

Recommended shape:

marriage_biodata
  id                uuid pk
  owner_user_id     uuid not null references public.users (id)   -- authorization anchor, always
  subject_relation  text not null check (in ('self','son','daughter','sibling','other'))
  subject_*         -- the person the proposal is about
  status            text check (in ('draft','open','discussing','closed','finalised','retired'))
  ...
  -- "one ACTIVE per account", expressed exactly:
  unique (owner_user_id) where status <> 'retired'

A partial unique index says "one active per account" precisely, without forbidding a retired sibling's biodata from continuing to exist — which answers a question the owner's rule leaves open and which the user journey flags.

⚠ The gap this does not close is consent. Nothing in the platform expresses "this record is about a person who is not the account holder, and they have agreed." That is ⬜ new, and it is a prerequisite rather than a refinement — see Privacy and security.

5. The public destination — ✅ reuse, and the precedent is exact ​

The owner wants the biodata to have its own destination, not to consume /<slug>.

The platform already supports this, and says so: "/<slug> IS A NAMESPACE ROOT, NOT A PAGE. Everything a vendor owns hangs off it as a sibling" (apps/web/src/app/routes.ts). Siblings already exist — /:slug/setu-card, /:slug/order, /:slug/order/:orderId/:reference.

And the PII posture has a worked precedent in this codebase. order-status.tsx is already a sensitive page, and its header states the rule:

"What must never happen is the page being CACHED or CRAWLED: an edge cache would serve one buyer's order to whoever asked next, and an index entry would publish it. Hence no-store and noindex, in headers() rather than only in meta(), because a crawler that never runs JS still reads the header."

⚠ A consumer biodata must inherit order-status.tsx's posture, NOT setu-card.tsx's. The vendor card is served s-maxage=604800 — seven days at a shared edge. Inheriting that on a PII page would hand one person's biodata to the next visitor for a week. The same file already warns about exactly this: "The headers below are the only thing standing between those two facts."

Two consequences follow, and both are good news:

  1. ADR-0027's purge-on-write cache design does not apply here. No edge caching means no invalidation problem, no campaign-style scheduled purge, and no dependency on the outbox that nothing drains.
  2. 🔵 But every view hits the origin. At the growth model's target of 500,000+ profile views that is a real infrastructure consideration, and it is the price of the PII posture rather than a bug.

On the URL shape itself: a vanity slug is enumerable, which on a PII surface is a harvesting risk. Recommendation: identity gets the slug; the biodata destination gets an unguessable token, with a readable vanity path as a later, opt-in addition.

6. Entitlements — ✅ reuse. Measured, and it already serves consumers ​

⚠ An earlier draft of this page said this "is not established". It is now measured, and the answer is better than assumed.

A consumer with zero workspaces DOES receive feature grants, and they resolve enabled = true:

  • resolve_features bypasses the applicability axis entirely for universal features (20260808150000:183, source 'universal' at :189). The workspace_id is null arm is only reached for scoped features.
  • The platform scope matches unconditionally, with no workspace predicate (:147).
  • The seed inserts a platform-scope entitlement grant for every universal feature, and its reason string says so verbatim: "Universal feature: unlocked for every principal INCLUDING CONSUMERS." (20260808150000:327-332)

So for a principal with p_workspace_id null, enabled is true for setu_card, qr_tools, profile, settings, notifications, analytics, plus chat and reminders via later platform grants. Only payments behaves differently.

Two of those are directly load-bearing here: setu_card and qr_tools are already universal and already granted to consumers. The entitlement system is not an obstacle to a consumer identity — it anticipated one.

🔵 What remains open is the commercial layer, not the resolver: platform_plans and paid feature_grants are workspace-shaped, so a consumer paid tier needs grants attached to a user. That is a data-model question for monetisation, not a blocker for the free feature.

6b. Photographs — 🔧 Extend media ownership ​

Photographs are the heart of a biodata, so this is the first place the consumer side needs something the merchant model never had to express.

Where it stands: media.workspace_id is not null (20260808170000_v2_locations_media_outbox.sql:87), because every media row so far has belonged to a business. A Consumer has no workspace, so the table has no way to say who owns a consumer's photograph. That is not a defect — it is a correct constraint written before this use case existed.

The general shape, and it is the important part ​

The slug and media are two instances of one thing: the platform can express a Consumer as a principal (they authenticate, they have a public.users row, get_my_context() answers for them) but not yet as an owner of a resource. Every consumer feature will meet this, so it is worth extending once, deliberately, rather than patching per feature.

Recommended shape — a nullable-pair ownership column with a CHECK:

sql
alter table public.media
  alter column workspace_id drop not null,
  add column owner_user_id uuid references public.users (id) on delete cascade,
  add constraint media_exactly_one_owner
    check (num_nonnulls(workspace_id, owner_user_id) = 1);

Why this shape rather than the alternatives:

  • Existing merchant rows and policies are untouched. workspace_id keeps its meaning; only its nullability changes. No backfill, no rewrite of merchant RLS.
  • The authorization pattern already exists here. Twenty of the forty-six live RLS policies already contain a direct auth.uid() comparison, so a consumer-ownership branch is idiomatic in this schema rather than novel.
  • num_nonnulls(...) = 1 makes "exactly one owner" a database guarantee, not an application convention — which is the difference between a rule and a hope.
  • It generalises. The same two-column pattern serves any future consumer-owned resource, and it is the row-level counterpart of the slugs(slug, owner_type, owner_id) registry recommended for the namespace.

What else moves with it: object keys are currently w/{workspaceId}/…; consumer media needs a sibling prefix (u/{userId}/…) so that storage-level lifecycle and deletion stay per-owner. And RLS gains one branch: owner_user_id = auth.uid().

Effort: one migration, one RLS branch per media policy, one key-prefix change in manage-media. Do it once, before the first consumer feature stores anything, because retrofitting ownership onto rows that already exist is the expensive version.

7. What has to be built — 🆕 ​

Everything else is reuse or extension. These have no equivalent yet, so each needs a design rather than a widening:

  • Consent for a third-party subject
  • Per-recipient access control — request, approve, revoke, expire. Feature grants resolve entitlements, not per-viewer permissions
  • A consumer identity object and its hub
  • Moderation, reporting and takedown — no queue, no workflow, no appeals path
  • Person verification — ADR-0026 covers vendor verification
  • A biodata schema, templates and renderer
  • A consumer analytics read model — the card-activity seams are stub-only

The decision order ​

Cheap now, expensive later, in this order:

  1. Slug ownership (🔧) — write-once, ends up on paper. Blocks everything else. ⚠ Now urgent: the owner has decided the consumer slug is claimed at onboarding (2026-08-28), so this is on the critical path of the very first consumer to sign up, not of the first biodata.
  2. Consent model for a non-subject owner (🆕) — changes the schema and every builder screen.
  3. individual → consumer rename (🟨) — free now, a backfill later.
  4. Per-recipient access primitive (🆕) — the core mechanic; nothing else can be designed around it.
  5. Then templates, analytics, monetisation.

⚠ Steps 1 to 4 must be settled before a design is requested, per CLAUDE.md's rule that an architecture-gated question is never sent to Claude Design. See Design.