Appearance
The QRSETU user ecosystem
Purpose: this page is the reference point for product, architecture, design and implementation decisions about who QRSETU serves — and how each of those people is represented as rows in the database. Authored 2026-08-07; data-model maps added 2026-08-08; fully re-measured against the live
qr-setu-devproject on 2026-08-26 — see section 0.1.Governing principle:
CLAUDE.md§ "Core product principle: THREE USER CATEGORIES" · Index of truth: current architecture state · Schema: ADR-0020 · Grants: ADR-0021 · Hierarchy: ADR-0023
0 · Currency check — validated against the schema, not against memory
⚠ This section is a HISTORICAL RECORD of the 2026-08-08 revalidation. Its figures were true then and are not current — "26 tables, 16 functions" is now 55 and 69. For the live numbers read section 0.1, which supersedes every count here. This section is kept rather than edited because it records why four claims were wrong, and that reasoning is the durable part.
The 2026-08-07 version of this page was written before the v2 baseline was authored, so it named tables by their working titles. Every table and function named below was re-checked against supabase/migrations/2026080*_v2_*.sql on 2026-08-08. Four things were wrong and are corrected here rather than quietly adjusted:
| Was | Now | Why it mattered |
|---|---|---|
"feature_grants · 9 scopes × 3 axes" (in the diagram) | 8 scopes × 3 axes | The page's own body text already said eight and explained that the ninth (account_type) was dropped. A diagram contradicting its own page is worse than either version alone. |
cards · card_links · plans · archetypes | setu_cards · setu_card_links · platform_plans · business_archetypes | Renamed 2026-08-08 under the no-generic-table-names rule. cards would collide with vCards / invitation cards / event cards; primitives collided with apps/mobile/src/ui's "systemic primitive set". |
roles.scope='platform', billing_accounts, seats, subscriptions, orders.buyer_user_id written as if they exist | Marked 🟠 Wave 2 — designed, not built | These carried the load of types 1, 2 and 4 in the old text with no indication they were unbuilt. A reader would have gone looking for a roles table. |
| Implied the schema was live | Now genuinely live | The page was corrected while Dev was empty; the owner applied v2_apply_all.sql later the same day and it is verified applied — 26 tables, 16 functions, resolver probed live (see below). The correction still belongs here: for several hours the page described a schema that did not exist. |
What did NOT change: every architectural claim. Principal = user, workspace = business tenant, membership count as the category discriminator, sharing-down/oversight-up, seats licensing users, consumer isolation. The design survived the schema being written, which is the outcome worth having.
0.1 · Re-measured 2026-08-26 — nine corrections
The page went 17 days and ~45 migrations without revalidation. Everything below was read from the live qr-setu-dev project (dyhjofjjuazhyqcvlrkx) on 2026-08-26 — catalogue queries, pg_get_functiondef for live bodies, list_edge_functions — not from a migration file. Full evidence: Admin Portal backend readiness.
⚠ The architectural model survived again. Every correction is a VALUE, a COUNT or an IMPLEMENTATION STATUS — not a decision. That is the third consecutive revalidation with that outcome, and it is the reason this page is worth keeping.
| # | Was | Measured 2026-08-26 | Why it mattered |
|---|---|---|---|
| 1 | "26 tables, 16 functions, 22 policies, 11 triggers" | 55 tables · 68 functions (17 return trigger, so 51 callable) · 45 policies · 34 triggers · 0 views | The spine grew by 29 tables. Anyone sizing work off the old figure was out by more than 2x. |
| 2 | workspaces.kind = solo / org_unit / location_unit | solo / organization only | Two of the three named values do not exist. A CHECK violation waiting for the first person who trusted the diagram. |
| 3 | workspace_members.role_key = owner / admin / manager / staff / agent | owner / admin / manager / member / viewer | staff and agent do not exist; member and viewer were missing. Same defect class as #2. |
| 4 | feature_grants.on_exceed = block_new / soft_warn / degrade | block_new / read_only / grace_period | Two of three wrong, and this is entitlement behaviour — a wrong value changes what a merchant experiences at their limit. |
| 5 | feature_grants.source = platform_admin / plan / org_admin / system | platform_admin / org_admin / vendor / system | plan is not a source; vendor was missing, and vendor is the value that tells a merchant's own choice apart from an operator's override. |
| 6 | "3 archetypes, ~45 industries" | 3 archetypes, 14 industries | Off by 3x. |
| 7 | orders (+ nullable buyer_user_id) listed as Wave 2, no table | BUILT — orders 19 rows, order_items 23, payments 18, buyer_user_id nullable exactly as designed | The consumer half of section 8's history answer is live, and the whole 9-table payment domain with it. Understating built work is the more expensive direction of drift: it invites rebuilding. |
| 8 | Type 3's subscription on billing_accounts(subject_kind='user'), Wave 2 | workspace_subscriptions exists — PK (workspace_id), 1 live row, plan_key -> platform_plans. billing_accounts does not exist. | The SUBJECT changed: the subscription is keyed to the workspace, not the user. And the PK means current state only, with nowhere to record a plan change (QRS-893). |
| 9 | "v2_isolation_test.sql carries 29 negative assertions" | The file exists and deliberately uses no_plan() | Its own header records that a hard-coded plan(36) "was wrong (an earlier revision said 31)". Quoting a fixed count in prose is exactly the drift that test removed, so this page no longer states one. |
Two claims on this page that are still exactly true, restated because they now have measured backing: primary_context is a preference and no function or policy reads it for authorisation; and no policy grants access by role alone — all 45 were read, and every merchant-data policy scopes by relationship.
0.2 · The lens this page was missing — three planes, not one hierarchy
Added 2026-08-26 after the owner rejected an earlier recommendation of mine. The six types below are correct, but listing them flat invites the error I actually made: treating internal staff as another kind of QRSETU user and proposing that the Ops team be modelled as a workspace with workspace_members rows.
Why that is wrong, and it is measurable rather than stylistic: membership count is the persona discriminator in this schema — consumer 0, solo owner 1, enterprise employee 1. Give an operator a workspace and they become indistinguishable from a solo merchant in every query that classifies a principal. Worse, my_workspace_ids() is called by most of the 45 policies, so a membership row would hand operators tenant-plane read reach as a side effect of being staff — invisible at every call site. That is privilege escalation by data entry.
Read the six types through three planes:
| Plane | Who | Represented by | Relationship to the others |
|---|---|---|---|
| Control plane — internal | Types 1-2 | platform_operators (decided, not built) | Acts ON the other planes, never IN them. Audited, actor_kind = 'support' |
| Tenant plane — external business | Types 3-5 | workspace_members -> workspaces | The business tenant. Roles here mean nothing outside their workspace |
| Consumer plane — external individual | Type 6 | zero memberships, or no user row at all | Relates to a business by transaction only — never membership |
Authentication is shared; authorisation is planed. One Supabase project has exactly one GoTrue user pool, so every principal must share auth.users — which is precisely why the discriminator has to be a table the control plane owns, not a property of the auth row. Full reasoning, alternatives considered and rejected, and the invariants: platform operator control plane.
⚠ The one thing this buys for free is worth stating, because it answers "is the boundary enforced by the data model or just hidden in the UI?" Every merchant capability — Setu Card creation, onboarding, catalogue, orders, payments, marketplace participation — is workspace-scoped and resolves through my_workspace_ids(). So one invariant (an operator holds zero memberships) makes all of them structurally unreachable. No per-capability rule is needed. ⚠ Consumer capabilities are NOT covered by it — conversations and reminders are user-scoped, so those need explicit guards; see the proposal's section 10.
1 · The master map — who the user is, what they belong to, what they can access
Read the diagram in five bands, top to bottom. Everyone enters through the same identity band; what differs is only which relationship rows exist beneath them.
The four things this diagram is drawn to make unmissable
- There is one
userstable and one identity band. Nomerchantstable, noconsumerstable, nostafftable. A person's category is a shape in the data, not a column. - Band 4 is keyed by
workspace_id, neveruser_id. That is what makes an employee's personal business invisible to their employer, and what makes a consumer's order history survive them later becoming a merchant. - Band 5 has no arrow into
users. Nothing about entitlement is stored on the person. It is resolved per call, which is why a plan change or an admin grant takes effect without touching a single user row. ANONhas no row. Not a null-object user, not a guest account — nothing. A visitor who never authenticates is represented only by theanonPostgres role reading two functions.
2 · How a category is DERIVED, never stored
The single most important line in this page: users.primary_context decides which dashboard you land on. It never decides what you can reach. Authorization asks workspace_members.
Why primary_context is not account_type. Renaming it was a security fix, not tidying. As account_type it read like a fact the system should trust; a user can flip their own preference, and anything keyed on it would have been a self-service escalation. It is also derivable — storing it would be the duplicate-source-of-truth defect this repo hit at QRS-249/284/287.
3 · The data model behind it — the identity, tenancy and grant spine
Real table names, real columns, real cardinalities, as authored in the v2 baseline.
Three modelling choices a reader should not have to reverse-engineer
workspaces.pathis a materializeduuid[], not a recursive CTE. Ancestor resolution appears in every enterprise RLS policy, and a CTE per policy evaluation is the difference between an index lookup and a walk. The cost is a trigger that cascades on re-parenting, plus a cycle guard.setu_cardsis a separate table fromworkspaces, not a view over it. The public surface being its own table is what removes the entire projection/allow-list defect class:anonreads a table whose every column is intended to be public, instead of a function that must remember to omitgstin. See ADR-0020 D2.feature_grants.reasonisNOT NULL. An entitlement override with no stated reason is unauditable six months later, and the person who needs it is always someone else.
4 · Enterprise: organization → workspace tree → locations → seats
The dealership case that drove the design. Sharing flows down; oversight flows up. They are two mechanisms and they are never symmetric.
The rule that keeps the tree from becoming an argument: if a place needs its own card and its own P&L it is a workspace; if it is only an address it is a location. A showroom is a workspace. A dairy's second collection point is a location.
Place a resource at the lowest node all its legitimate consumers descend from. Customers at the showroom, so Sales and Service join by construction rather than by an integration. Vehicle stock at Sales. Brand assets at the root.
5 · "What can this user access?" — the resolution path
PROVEN ON DEV, not merely designed (2026-08-08)
Probed live with two fixture workspaces of the same archetype (goods) and different compositions, then removed:
| feature | dairy | boutique |
|---|---|---|
khata (needs the Balance primitive) | derived:composition | derived:not_in_composition |
store (needs Catalogue) | derived:composition | derived:composition |
settings | universal | universal |
Zero feature_grants rows were involved in those applicability answers — that is ADR-0021's central claim, and it is the dairy-vs-boutique test that produced the whole primitive model.
The consumer case was probed too: with p_workspace_id => null, every merchant feature returns no_workspace (not applicable, rather than denied) while settings and setu_card still resolve universal. A consumer is served by construction rather than by an exception.
One function answers it, and it answers it the same way for all six user types. App code never branches on archetype, industry or plan — that is lint-gated. It asks for a feature.
The two axes that fail in opposite directions, deliberately
| Axis | Default | Why that direction |
|---|---|---|
| Entitlement | DENY | A feature that works without being billed produces revenue nobody ever reports. Silence is the wrong failure. |
| Availability | ALLOW | A shipped feature that nobody can reach is a wasted release. If nothing says "hidden", it is visible. |
Getting either backwards is invisible in testing and expensive in production, which is why the three *_source columns are returned: a support engineer can see which row produced the answer.
Precedence, when several rows apply: scope.precedence DESC (platform 10 → user 80), then source_rank DESC. So a per-member grant beats a plan grant, and an explicit platform_admin row beats a system one at the same scope.
6 · Access matrix — who reads and writes what, and by which mechanism
The column that matters is the last one. No policy grants access by role alone — every merchant-data policy scopes by relationship.
| Data | 1 Platform Admin | 2 Ops | 3 Solo owner | 4 Org admin | 5 Seat user | 6 Consumer | Anon | Mechanism |
|---|---|---|---|---|---|---|---|---|
users (own row) | R/W | R | R/W own | R/W own | R/W own | R/W own | — | id = (select auth.uid()) |
workspaces | R/W | R | R/W own | R/W in org | R own | — | — | id = any(my_workspace_ids()) |
setu_cards (draft) | R/W | R | R/W own | R/W in org | R/W own | — | — | membership |
setu_cards (published) | R/W | R | R/W own | R/W in org | R/W own | R | R | get_public_setu_card |
catalog_items | R/W | R | R/W own | R/W in org | R/W own | — | — | membership ∪ oversight ∪ org-share |
| Public catalogue | R | R | R/W own | R/W | R/W | R | R | get_public_catalogue |
| Descendant workspaces' data | R/W | R | n/a | R only | — | — | — | my_oversight_workspace_ids(), org_owned only |
An employee's personal member_owned business | R/W | R | n/a | ⛔ never | own | — | — | oversight is gated on ownership_model |
feature_grants | R/W | ⛔ | R own effect | R/W in org | — | — | — | separation of duties |
platform_plans, industries, features | R/W | R | R | R | R | R | R | public registry reads |
audit_log | R | R | ⛔ | ⛔ | ⛔ | ⛔ | ⛔ | append-only, no UPDATE/DELETE policy exists |
⚠ The two admin columns above are NOT IMPLEMENTED, and they never will be as RLS
Measured 2026-08-26: 0 of 45 policies reference an administrator, and anon and authenticated hold ZERO table privileges (information_schema.role_table_grants returns no rows for either). So every R/W in the 1 Platform Admin and 2 Ops columns is aspirational — there is no mechanism behind it today.
And the decided architecture is that there never will be one at this layer. Per the platform operator control plane proposal: operator authority lives in a platform_operators table read server-side on every request, and reads go through narrow, per-screen SECURITY DEFINER RPCs behind an admin Edge Function. Two reasons, and the second is the one that matters:
- A policy cannot grant what the role does not have. With zero table grants,
TO authenticated USING (is_platform_operator())grants nothing. "Add an admin policy" is not an available move. - An RLS bypass is invisible at the call site; a named RPC is not. Making the control plane legible in code review is what makes it auditable — which is the whole point of a control plane.
Read the two admin columns as INTENT, and the last column as applying to types 3-6 only.
⚠ The threat-model change that no test would have caught. Until consumers existed, authenticated ≈ merchants ≈ a small semi-trusted population. Category 3 makes authenticatedthe logged-in general public, and it will be the largest population on the platform by orders of magnitude. Nothing failed when that became true. So: no using (true) TO authenticated, anywhere, ever. supabase/tests/database/v2_isolation_test.sql carries the negative assertions that prove a consumer with zero memberships can read nothing merchant-owned. ⚠ No count is quoted, deliberately — the file uses no_plan() because a hard-coded plan was wrong twice (its own header: "the previous file hard-coded plan(36) and the count was wrong (an earlier revision said 31)"). Read the count off a run, never off this page. ✅ Re-verified 2026-08-26 by reading all 45 live policies: the invariant holds, and anon/authenticated additionally hold zero table grants, so the door is shut twice.
7 · The six user types
1 · QRSETU Platform Admin
| Purpose | QRSETU staff. Owns the platform's configuration: the feature registry, plans, archetypes, industries, palettes, template lifecycle, and grants at every scope. |
| Represented as | A users row with zero workspace_members rows, plus a row in platform_operators with operator_role = 'owner'. ⚠ The table does not exist yet, but its shape is now DECIDED rather than open — see the platform operator control plane proposal (check:arch-proposal green, 2026-08-26). Measured: there is still no roles table, no is_admin() among 69 functions, and no role claim in raw_app_meta_data. |
| Onboarding | Provisioned, never self-serve. Granted by an existing platform admin, with an audit_log row. |
| Dashboard | apps/web admin tier — Overview · Campaigns · Workspaces · Plans & limits · Moderation · Feature Control (the archetype × feature grid) · Verticals. Not built. |
| Permissions | Full Manage/Configure across the registry. Every mutation writes audit_log — an admin surface that can change entitlements without an audit trail is unauditable by construction. ⚠ Measured 2026-08-26: audit_log has 0 rows, 0 policies and NO WRITER (QRS-890), so this sentence describes a requirement, not behaviour. It is P0 for exactly that reason. |
| Feature access | Not entitlement-gated. Platform admin is a governance role, not a subscription tier. |
| Relationships | Governs types 3–6 through feature_grants. Does not act inside a tenant's data on their behalf — that is type 2's job, under narrower powers. |
2 · QRSETU Internal Operations
| Purpose | Support, moderation, abuse handling, takedown, reconciliation triage. Deliberately distinct from type 1, and the distinction is a control, not an org chart. |
| Represented as | As type 1, with operator_role = 'operator'. ⚠ v1 ships TWO roles, not a permission matrix — owner (provisions and revokes operators) and operator (the day job). ADR-0006's modules x 9 actions grid stays out of v1: it is 0% built and unjustifiable for two roles and five people. Graduate to platform_roles + permissions + assignments when either trigger fires: a permission is needed that its role does not imply, or a role must be time-bounded. |
| Dashboard | Same admin surface, fewer tabs; read-mostly. |
| Permissions | View broadly; Approve/Publish on moderation; no Configure on the feature registry, no plan mutation, no payout-account access. |
| Relationships | ⚠ Separation of duties is the whole point: the person who resolves a billing complaint must not also be able to grant the entitlement that resolves it. ADR-0006's dependency map and risk_tier are what make the split enforceable rather than advisory — and with only two v1 roles the split is by convention until that grid exists. Say so rather than implying it is enforced. |
3 · Solo Business Owner — primary R1 audience
| Purpose | An independent SMB digitizing and growing their business: Ganapati stall vendors, boutiques, Herbalife distributors, real-estate agents, electricians, tutors, yoga trainers, consultants. |
| Represented as | users + exactly one workspace_members row → a workspaces row with organization_id IS NULL and ownership_model = 'member_owned'. |
| Onboarding | Self-serve in apps/mobile. Chooses Business at the fork, then industry → archetype, brand, slug. Publishing is fully open self-serve. |
| Authentication | Google sign-in — measured working and the ONLY provider in use (auth.identities: google=7, and 0 of 7 users have a password). Email OTP is still broken (QRS-285, SMTP 535: Supabase points at Hostinger while the verified sender is ZeptoMail). ✅ WhatsApp OTP is now the decided direction (owner, 2026-08-26): Supabase Auth generates and verifies the code and issues the session, and the Send SMS Hook — an Edge Function — delivers it via our own Meta Cloud API, so ADR-0029's "Meta direct, no BSP" holds. ⚠ Supabase's NATIVE channel:'whatsapp' is Twilio-only and must not be used. |
| Dashboard | apps/mobile user tier: Setu Card, Store/Catalog, Payments, Leads, Contacts, Bookings, Appointments, Analytics, Reminders. |
| Feature access | All three axes: applicability from their industry's composition, entitlement from their plan, availability from flags. |
| Subscription | workspace_subscriptions EXISTS — PK (workspace_id), plan_key -> platform_plans (free / pro / business at Rs 9,999 / enterprise), 1 live row. ⚠ The subject is the WORKSPACE, not the user — billing_accounts(subject_kind='user') was never built and is not the shape. ⚠ The PK means current state only: there is nowhere to record a plan change (QRS-893); the pattern to copy is workspace_tax_identity_history. ⚠ And there is still NO collection path for the platform subscription — Model A in the payments SSOT is unbuilt; only marketplace payments (Model B) are live. Billing stays web-first via Razorpay; the native app carries no IAP UI or CTA (Apple 3.1.3(d), ADR-0002). |
| Relationships | Publishes a card that types 6 and anonymous visitors consume. Chooses their own archetype and plan — the defining contrast with type 5. |
4 · Enterprise Organization Admin
| Purpose | A customer's administrator, onboarding many users under one centralized subscription. |
| Represented as | users + membership in an org_owned workspace at or near the tree root, with an organization-scoped role. 🟠 Role store is Wave 2. |
| Onboarding | Provisioned when the organization is created (sales-assisted in practice), then self-serve inside their org. |
| Dashboard | Their own admin portal — a surface that does not exist yet. ⚠ ADR-0011's Stack 1 enumerates public cards, landing and platform admin only; this is a fourth tier and needs an ADR-0011 amendment plus a Claude Design pass before implementation. |
| Permissions | Powerful inside one organization, powerless outside it. Invites and revokes seats, assigns employees' archetype/industry, sets org policy, toggles the five share_* flags. |
| Feature access | Their org's plan, plus grants at workspace_group / workspace scope. |
| Subscription | 🟠 Wave 2 — one centralized, seat-based subscription. Seats license users, not workspaces. |
| Relationships | ⚠ Never confuse this with type 1. A platform admin governs all tenants; an org admin is a customer's employee. Conflating them is privilege escalation. |
5 · Enterprise Member / Seat User
| Purpose | An employee or member operating a business presence provisioned and governed by their organization. |
| Represented as | users + one membership in a workspace whose ownership_model = 'org_owned' and whose path descends from the org root. |
| Onboarding | Invited, not self-serve. They do not pick their own industry, archetype or plan — the org admin does. This is the structural difference from type 3. |
| Dashboard | The same apps/mobile merchant app as type 3, with org-governed configuration and possibly a narrower feature set. |
| Feature access | Org plan + org-level grants; the org admin may restrict further at workspace_member scope. |
| Subscription | None of their own. On seat revocation, org_owned suspends the workspace — contrast the MLM case at member_owned, which drops to free and keeps the card. |
| Relationships | ⚠ Data isolation, stated because getting it wrong is a privacy breach: org visibility is scoped per workspace the org owns, never per user. An employee may also be a consumer (type 6) and may run their own personal member_owned business. An org admin must never be able to see either — which is why my_oversight_workspace_ids() filters on ownership_model, not merely on the tree. |
6 · Individual Consumer / Service Seeker
| Purpose | Customers, students, service seekers, end users — people who meet QRSETU by interacting with a business, not by running one. |
| Represented as | users with zero workspace_members rows, or no users row at all — orders carries buyer_user_id (nullable) plus buyer_name / buyer_phone / buyer_email, so an anonymous buyer is first-class in the money path. Business features are not applicable rather than denied, because there is no workspace for them to apply to. ⚠⚠ Measured 2026-08-26: ZERO consumers exist (all 7 users are primary_context = 'business'), and handle_new_user() DEFAULTS to 'business' — so a consumer signing up through a card today is provisioned as a business principal unless the client passes metadata. The consumer population is not merely empty; the default writes the wrong value (QRS-892 family). Two users do have zero memberships, which is structurally the consumer shape with the wrong label. |
| Onboarding | Anonymous first, and this is non-negotiable. A vendor's card is fully usable with no account. Registration is demanded only at a genuine identity boundary: chat, appointment booking, consultation scheduling, purchase, order tracking, subscription management. A signup wall in front of a scanned card destroys the platform's growth mechanic. |
| Dashboard | A completely different apps/mobile individual tier — bookings, orders, vendor subscriptions held, saved businesses. ⚠ "Claude Design first" is STALE: the designs already exist. prototype/consumer/ holds 13 screens (measured 2026-08-26 via list_files), and CLAUDE.md scopes them as R1. So this is a design PULL, not a design REQUEST — sending a prompt would generate a second, competing design for screens that exist (QRS-451's lesson). ⚠⚠ AND THE ROUTING CLAIM I FIRST WROTE HERE WAS FALSE, inherited from a stale CLAUDE.md note. It said "entryRoute.ts contains 0 occurrences of consumer, so an individual routes to the merchant dashboard". Measured 2026-08-26 from source: FIVE occurrences and a real branch — resolveEntryRoute returns { pathname: '/consumer' } for an individual, and hasCompletedOnboarding opens with if (primaryContextOf(ctx) === 'individual') return true; so a consumer is complete without a workspace. Both halves were fixed under QRS-730. provisionWorkspace is likewise no longer unconditional — OnboardingSetup branches, and the individual fork calls setPrimaryContext('individual') instead. |
| Feature access | Consumer features at platform scope. Merchant features resolve to not_applicable / no_workspace. |
| Subscription | No QRSETU plan. May hold subscriptions sold by a vendor (a gym membership, a tuition plan) — vendor commerce, not a QRSETU plan. Conflating the two is the trap in §8. |
| Relationships | Consumes types 3 and 5. The marketplace on-ramp: as QRSETU evolves, this population is the demand side. |
8 · The individual → business transition
The owner's question: a student who books classes, or a customer interacting with a vendor, later wants their own Setu Card. What is the recommended flow?
Recommendation: it is an ACQUISITION, not an upgrade — and "upgrade" is an actively harmful framing
The person does not stop being a consumer. The student who becomes a yoga teacher still books other people's classes. The customer who opens a boutique still buys from other vendors. So a model that converts individual → business breaks the half it converted away from, and every question below gets hard. Model it as the same principal acquiring a business context, and they all get easy:
| Question | Answer |
|---|---|
| Upgrade the account, or create a separate business profile? | Neither. One users row gains one workspaces row + one workspace_members row. Nothing is converted; nothing is duplicated. |
| How is consumer history preserved? | By construction — it was never on the business entity. 🟠 Wave 2: orders they placed hang off orders.buyer_user_id; orders they receive hang off orders.workspace_id. Two columns, two relationships, zero collision. This is the payoff of separating principal from tenant. |
| One login, multiple roles? | Yes, necessarily — and it is not "multiple roles", it is one identity with a set of relationships. Roles are already per-workspace (workspace_members.role_key). |
| How do permissions and dashboards change? | Business surfaces become applicable the moment a membership exists — resolve_features flips from no_workspace to derived:composition with no write to the user. The person now needs a context switcher (consumer ⇄ business), which is a screen that does not exist → Claude Design first. |
| Subscriptions and billing during conversion? | Nothing converts. See below — this is the one place a naive design does real damage. |
| Enterprise impact? | An employee may also be a consumer, and may run a personal business subject to org policy. Org visibility stays per-workspace, never per-user (type 5 above). |
The falsifiable acceptance test for the design
The individual → business transition must be a pure
INSERT. If it requires anUPDATEto anything consumer-side, a row move, or a data migration, the model is wrong and should be fixed rather than scripted around.
That single criterion is worth more than a written procedure, because it is checkable in a pgTAP test and it fails loudly if someone later couples the two identities.
Billing: two subjects that must never merge
- A consumer's vendor subscriptions (a gym membership sold by a merchant) are vendor commerce — a catalogue item with a recurring shape, settled through the vendor's payments. They are not QRSETU plans and must never be resolved through
platform_plans. - A business's QRSETU plan is a subscription against a billing account whose subject is the user (solo) or the organization (enterprise). 🟠 Wave 2.
Billing accounts are therefore not unique per subject — one person may legitimately hold several (their own business's plan, and later a second business). Modelling it as one-per-user would force a migration the first time someone runs two businesses.
Reversibility
Someone who tries a business and abandons it sets workspaces.status='closed'. Their consumer identity, order history and vendor subscriptions are untouched, and their card stops resolving. Nothing is deleted, and re-opening is another pure state change. A transition that cannot be walked back is a support burden disguised as a feature.
9 · Two corrections to previously-recorded decisions
Working this through changed two things, both recorded rather than quietly adjusted:
users.account_type is renamed primary_context and is a PREFERENCE, never a security input. It selects the default landing experience and nothing else. The authoritative answer to "does this person have a business?" is derived from workspace membership, always. Two reasons this matters: a user-mutable column that grants access is a privilege-escalation primitive (flip your own preference, gain grants); and storing a fact that is already derivable is the duplicate-source-of-truth defect this repo has hit at QRS-249/284/287.
The account_type grant scope is DROPPED, taking the total from nine scopes back to eight. It was added a day earlier on the reasoning that "enable discovery for all individual accounts" needed a home. It does not: consumer features are granted at platform scope (merchants are consumers too, so they should see discovery as well), and business features are simply not applicable without a workspace — which the applicability axis already answers. A scope resolving on a derived fact would have been the same duplication the rename above removes.
9.1 · Two decisions taken 2026-08-26
D1 · An employee who trades is a merchant, on a separate account. A QR Setu employee who wants to run a business opens a dedicated merchant account, separate from their operator credential. No reverse guard — an operator session reaching the consumer app is not forcibly signed out; it simply resolves to a principal with no workspace and no merchant data.
⚠ Taken literally that would have reversed itself, because nothing then stops an operator completing merchant onboarding and acquiring a membership. Enforced at the provisioning seam instead, which is measurably complete: provision_merchant_workspace() is the only writer of workspace_members (granted to service_role alone, and the table has no INSERT policy at all), so one guard inside that function is a complete boundary rather than a best-effort one.
D2 · No MFA for operators (QRS-899). Owner decision, recorded with its blast radius: without a second factor a leaked operator password is full read access to every tenant. Compensating controls agreed in the same decision, all zero login friction — enable leaked-password protection (measured off), admin-generated long random initial passwords with must_change_password, a recent-authentication requirement read from the amr claim, and audit_log as the primary control rather than a compliance artifact. Revisit triggers are on the tracker row.
10 · What the diagrams show as 🟠 Wave 2 — designed, not built
Read every 🟠 above as an open item, never as a feature. The identity, tenancy, card, catalogue, grant and media spine is applied and verified on Dev — re-measured 2026-08-26 as 55 tables, 68 functions (17 of them trigger functions, leaving 51 callable), 45 policies, 34 triggers, 0 views, with repo and Dev set-equal on migrations in both directions (69 ↔ 69, no orphan, none unapplied) and the deployed Edge Function set equal to the repo's (10 = 10, no extra).
Re-measured 2026-08-26. Two rows moved to BUILT; the rest are still absent, and the absences were confirmed by naming the search space rather than by memory:
| Item | Status 2026-08-26 | Carries the load of | Tracked |
|---|---|---|---|
roles / permission store | 🔴 still absent — no table matching %role% or %permission% among all 55 | Types 1, 2, 4 | ADR-0006 · proposal |
platform_operators | 🔵 DECIDED, not built — shape approved 2026-08-26 | Types 1 and 2 | proposal · QRS-889 |
billing_accounts · seats | 🔴 absent | Type 4's seat model | ADR-0002, ADR-0023 |
workspace_subscriptions | ✅ BUILT (1 row) — but current-state only, no history | Type 3's subscription | QRS-893 |
orders (+ nullable buyer_user_id) | ✅ BUILT — 19 orders, 23 items, 18 payments, 9-table payment domain live with reconciliation | The consumer half of section 8's history answer | payments SSOT |
parties · schedules · ledger · balances · assets | 🔴 absent | CRM, bookings, khata, AMCs | Wave 2 primitives |
leads / contacts | 🔴 absent — and now on the critical path, because the Admin CRM is the first deliverable | The Ops team's Excel replacement | Admin v0.1 |
campaigns | 🔴 absent | The enterprise differentiator | ADR-0025 |
analytics_events · daily rollups | 🔴 absent — 0 views in public, and no %event%/%activity% table | Every dashboard number, and every lifecycle figure | ADR-0010 · QRS-891 |
| WhatsApp: templates · consent · message log | 🔴 absent | Type 3's and type 6's notifications; WhatsApp OTP delivery | ADR-0029/0030 |
| Any scheduler | 🔴 absent — pg_cron and pg_net are NOT installed, and outbox exists with nothing draining it | Every proactive nudge, reminder and campaign send | QRS-885 |
⚠ audit_log deserves its own line because it is the one that looks built and is not: the table exists with 15 well-designed columns and has 0 rows, 0 policies and no writer (QRS-890).
11 · Gaps this exercise surfaced
| Gap | Status |
|---|---|
| Enterprise org-admin portal — a fourth Stack 1 tier | Needs an ADR-0011 amendment and a Claude Design pass. Not started. |
| Consumer (individual-tier) dashboard | ⚠ Corrected 2026-08-26: the DESIGNS EXIST — prototype/consumer/ holds 13 screens. This is a design pull, not a request. What is missing is the client tier (apps/mobile/src/tiers/consumer/), the guardrail entry, and a consumer entry route: measured, entryRoute.ts contains 0 occurrences of consumer, so an individual with onboardingCompleted routes to the merchant dashboard. |
| Context switcher (consumer ⇄ business) | Does not exist. ✅ And it is no longer needed for STAFF — owner decision 2026-08-26: a QR Setu employee who wants to trade opens a separate merchant account, so an operator never switches context. Enforced at the single membership write path (provision_merchant_workspace, service_role-only), not by a UI rule. Still required for a genuine consumer who later starts a business — that is section 8's acquisition path, unchanged. |
| Role / permission store | ADR-0006 governs and is still 0% built (re-confirmed 2026-08-26). ✅ But types 1 and 2 no longer wait on it: the platform operator control plane gives them a two-role table now, with named triggers for graduating to the full grid. Types 4 and 5 still depend on ADR-0006. |
| Consumer↔vendor workflow tables — chat, bookings, consultations | The registration triggers, so they define what the consumer identity must support. Deferred; identity hooks ship now. |
TO authenticated policy audit | QRS-375. ✅ Re-verified 2026-08-26 by reading all 45 policies: still closed by construction — every merchant-data policy scopes by relationship, none grants by role alone, and anon/authenticated hold zero table grants on top. supabase/tests/database/v2_isolation_test.sql holds the line and uses no_plan() deliberately, so no fixed assertion count is quoted here (its own header records that a hard-coded plan was twice wrong). ⚠ One residual, logged rather than waved through: catalog_item_media_select and catalog_item_variants_select scope only indirectly, through catalog_items' own SELECT policy applying inside their subquery. Structurally sound, not proven by pgTAP — QRS-897. |