Appearance
Architecture and backend validation: every capability against the real schema
⚠ This page exists because a feature catalogue that has not met the schema is a wish list
📘 Owner instruction 2026-08-23: "I don't want a half-cooked, assumption-based dealership feature catalogue that later turns out to conflict with the actual QR Setu backend."
🧮 Every verdict below was checked against the live migration set, not inferred from the product pages. Where the answer is "no table exists", it says so. QRS-855.
The verdict scale
| Meaning | |
|---|---|
| 🟢 Supported | Existing tables and policies carry it. Client work only |
| 🔵 Minor modification | Additive columns, one new policy, or a projection change. No new concept |
| 🟡 Architectural enhancement | New tables and relationships, inside the existing tenancy model. The model holds; the schema grows |
| 🟠 Significant redesign | Touches the tenancy, identity or isolation model itself |
| 🔴 Not recommended | Refused under the current architecture, with a reason |
1 · What the platform already provides
🧮 Measured, and this is the part that makes the vertical viable at all.
| Capability | State |
|---|---|
organizations + workspaces TREE — parent_id, materialized path uuid[], depth cap 6 | 🟢 built, pgTAP-proven |
ownership_model — member_owned / org_owned | 🟢 built |
Three RLS helpers — my_workspace_ids, my_oversight_workspace_ids, my_shared_org_ids | 🟢 built and proven |
setu_cards per workspace, org_owned, slug write-once trigger | 🟢 built |
feature_grants — 8 scopes × 3 axes, limit_value, on_exceed | 🟢 built |
industries text key · business_archetypes · process_primitives | 🟢 built |
Reminders — rules + sparse occurrence exceptions, recurrence expansion in @qrsetu/domain | 🟢 built and live on Dev |
| Orders / payments / reconciliation — monotonic status, fallback correlation, detect-only reconciler | 🟢 built and live |
| Chat | 🟢 schema shipped 2026-08-11 |
outbox with a cache.purge topic | ⚠ table exists, nothing drains it |
⚠ And the four gaps that gate almost everything in this vertical
| Gap | Consequence |
|---|---|
RBAC is 0% built — no roles, permissions, role_assignments, is_admin() | Every persona view, every rollup, every scoped read. The largest single dependency |
| Five of the seven primitives this vertical declares have ZERO tables — party, schedule, resource, asset, campaign | Visitor register, leads, customers, test drives, vehicle history, campaigns |
No write path to organizations anywhere in the product | The only INSERT is a test fixture. Group Intelligence, seats and org billing are all downstream of it |
No public media URL — mediaUrl.ts fails closed, no bucket provisioned | Every catalogue image on every public card (QRS-852) |
2 · The validation matrix
| # | Capability | Verdict | New tables / entities | Tenant boundary | ⚠ The real risk |
|---|---|---|---|---|---|
| 1 | Organisation Setu Card | 🟢 | none | workspace | Media URL blocks its content, not its existence |
| 2 | Employee Setu Cards | 🟢 | none | workspace + user | org_owned must never become member_owned |
| 3 | Multi-outlet group structure | 🟢 | none | org + subtree | Depth cap 6; a 3-level dealership fits |
| 4 | Catalogue and offers | 🔵 | none | workspace | get_public_catalogue projects storage_key, not a URL |
| 5 | Service and insurance reminders | 🔵 | link to asset | workspace | Needs #9 first; the engine itself is live |
| 6 | Upward oversight rollups | 🔵 | none | subtree, read-only | ⚠ must exclude member_owned descendants |
| 7 | Plan limits — cards, logins, storage | 🔵 | none | workspace | on_exceed needs soft-vs-hard semantics (QRS-850) |
| 8 | Party / customer registry | 🟡 | parties, party_identifiers, party_workspace_links | ⚠ undecided — see §3.1 | The most consequential open question on this page |
| 9 | Vehicle assets + service history | 🟡 | assets, asset_events | workspace, party-linked | Odometer and date recurrence both needed |
| 10 | Visitor register | 🟡 | visits | workspace | Must be offline-tolerant and faster than a pen |
| 11 | Leads + follow-up SLA | 🟡 | leads, lead_events, sla_policies | workspace, assignee-scoped | SLA clock is derivation → @qrsetu/domain, not SQL |
| 12 | Test drives | 🟡 | schedules, resources, bookings | workspace | Vehicle as a bookable resource |
| 13 | Targets and performance | 🟡 | targets, target_assignments | subtree | Assignable to a subtree, per ADR-0024 |
| 14 | RBAC and role hierarchy | 🟡 | roles, permissions, role_assignments | subtree-scoped | ⚠ See §3.2. Nothing above Team Leader works without it |
| 15 | Card scan analytics | 🟡 | interaction_events | workspace | Beacon 404s; profile_analytics has no writer |
| 16 | Per-standee touchpoints | 🟡 | qr_touchpoints | workspace + placement | ⚠ See §3.3. Without it, ₹225 buys printing |
| 17 | WhatsApp send, templates, opt-in | 🟡 | message_templates, opt_ins, message_log, credit ledger | workspace | ⚠ Per-workspace send cap is non-deferrable |
| 18 | Campaigns | 🟡 | campaigns, campaign_targets | org, targeted at a subtree | ⚠ Needs an outbox drain worker, which does not exist |
| 19 | Google Business integration | 🟡 | external_accounts, reviews | workspace, one GBP location per outlet | External approval gate; token storage |
| 20 | Seats and org subscription | 🟡 | org_subscriptions, seat_assignments | org | Org-level subscription is inexpressible today |
| 21 | Group Intelligence | 🟡 | none of its own | org subtree | Downstream of 8, 11, 13, 14, 20. Last, by dependency |
| 22 | Physical standee orders | 🔵 | reuse orders with a goods line type | workspace | ⚠ orders is marketplace-shaped; a goods COGS is new |
| 23 | Discount governance | 🟡 | discount_approvals | workspace | ⚠ Never an OEM-facing view. The CCI order |
| 24 | F&I attach workflow | 🟡 | attach_offers | workspace | Workflow only. Never the commission |
| 25 | Dedicated per-tenant deployment | 🟠 | none | ⚠ project, not workspace | See deployment and isolation |
| 26 | Cross-dealer benchmarking | 🟠 | aggregate store | ⚠ crosses tenants | 📘 Would likely make QR Setu a Data Fiduciary (QRS-837) |
| 27 | Featured marketplace placement | 🔴 | — | — | 📘 promo_slot fails closed pending compliance_profile; no consumer base (QRS-849) |
| 28 | OEM DMS integration | 🔴 | — | — | 📘 D3. No Indian OEM publishes an API; the card makes it unnecessary |
| 29 | Insurance / finance commission | 🔴 | — | — | Licence-gated: MISP appointment, RBI Digital Lending Directions |
| 30 | Accounting, GST filing, parts inventory | 🔴 | — | — | Tally and Marg territory; the OEM owns parts |
🧮 Count: 3 🟢 · 5 🔵 · 17 🟡 · 2 🟠 · 4 🔴. 🔎 The shape is the finding. Nothing needs a tenancy redesign — the four-layer model absorbs the whole dealership product as new tables inside an existing boundary. The cost is not architectural risk; it is volume of build.
3 · The six questions the matrix cannot answer in a row
3.1 · ⚠ Is a customer one record across tenants, or one per tenant?
This is undecided, it is the most consequential item on the page, and it is a privacy question before it is a schema question
A buyer visits three showrooms in one group, and separately visits a dealer on a different QR Setu tenant. Is that one party row or four?
| Consequence | |
|---|---|
| One global party, linked to many workspaces | The group view is correct and de-duplicated. ⚠ But dealer A can potentially learn their prospect visited dealer B — a cross-tenant leak of one customer's behaviour to a competitor |
| One party per workspace | Perfect isolation. ⚠ The group view double-counts, and cross-brand consolidation — the moat — degrades to a name-matching exercise |
Recommended shape, and it is a third option: parties scoped per organisation, with party_identifiers (phone-primary) unique within the organisation, never globally.
| Result | |
|---|---|
| Within a dealer group | ✅ De-duplicated. Cross-brand consolidation works, which is what the group pays for |
| Across QR Setu tenants | ✅ No linkage at all. Two dealer groups can hold the same phone number and never know |
| Consumer-side identity | 📘 Separate concern — a registered consumer's own account is users, and orders already carries a nullable buyer reference |
⚠ Do not make the phone number globally unique. It is the obvious implementation and it silently creates a cross-tenant join on the most personal identifier in the system.
3.2 · ⚠ RBAC is not one feature; it is the floor under nine personas
🧮 No roles, no permissions, no role_assignments, no is_admin() (QRS-803).
| Requirement | Why the ordinary implementation is wrong here |
|---|---|
| Roles assign to a SUBTREE, not a workspace | 📘 ADR-0023 D4. A Sales Manager over three outlets is one assignment, not three. A per-workspace role table cannot express it without duplication, and duplication is how a revoked role survives in one row |
Oversight is READ-only and org_owned-only | An ancestor may read descendants; ⚠ never an employee's member_owned business |
| Policies must not grant by ROLE alone | 📘 authenticated will become the logged-in general public once consumers ship. Every merchant-data policy scopes by relationship, never using (true) TO authenticated |
| The client gate is an affordance, not enforcement | 📘 The Edge Function is the primary write-enforcement layer; RLS is defence in depth. Both always |
⚠ Scalability note that will not surface until there is data
🔎 A subtree predicate calling my_oversight_workspace_ids() per row will dominate every query on leads and interaction_events — the two largest tables this vertical creates. The materialized path uuid[] makes a subtree test indexable (path @> ancestor_path with a GIN index), which is why path exists. The helper must be STABLE and the policy must compare against the indexed path, not re-derive the set per row. Get this wrong and it works perfectly at 500 rows and collapses at 500,000.
3.3 · Per-standee touchpoints — the entity that makes ₹225 defensible
text
qr_touchpoints
├── workspace_id the outlet
├── code unique, printed on the standee
├── placement reception | waiting | table | vehicle | desk | test_drive_bay
├── label "Table 4", "Creta display"
├── target org card | a specific catalogue item
└── active so a damaged standee can be retired without losing its history🔎 A scan resolves code → touchpoint → workspace → card, and the interaction event carries the touchpoint_id. That is what turns "someone scanned" into "the waiting-area standee produced 6 enquiries". ⚠ Without this table the standee is a printed sticker and the ₹225 has no argument behind it.
📘 Note the attribution consequence: a standee scan has no employee, so interaction_events.employee_id must be nullable and the outlet becomes the attributed party. A schema that requires an employee on every interaction cannot record the highest-volume interaction type in the product.
3.4 · Campaigns need a drain worker that does not exist
🧮 public.outbox exists with a cache.purge topic and nothing consumes it — no worker, no cron, no subscriber. So enqueuing a campaign send today writes rows nobody processes.
⚠ 📘 And a campaign is the first render-time temporal gate in the platform. Every prior decision kept those away from rendering because they break the edge cache. The answer is scheduled purge at campaign boundaries via the outbox — never a short TTL, never client-side. So the drain worker is a prerequisite for campaigns and for card freshness at once, which is an argument for building it once, properly.
3.5 · Audit and logging
| Requirement | State |
|---|---|
Structured Edge Function logs → Supabase/Logflare via console.log | 🟢 live. ⚠ There is no SQL log table by design — the previous one was dropped and its helpers still compile |
Error and crash reporting via the @qrsetu/observability seam | 🟢 live, PII-scrubbed |
| Tenant-scoped audit trail — who changed a role, a target, a card | ✅ CORRECTED 2026-08-23: public.audit_log EXISTS since 20260808200000_v2_audit — actor_user_id on delete SET NULL plus actor_role denormalised, before/after jsonb, a constrained action, scope_kind/scope_id, request_id, ip_hash. ⚠ My "does not exist" was a prefix-grep false negative: the table is audit_log, not audit (QRS-869) |
| Immutable payment event log | 🟢 payment_events, append-only, unique on (provider, provider_event_id) |
⚠ So the narrowed gap is not the table, it is the WRITERS. audit_log can record a role change today; nothing produces one, because roles do not exist. 🔎 That is a better position than it sounded: the schema decision is already taken and correctly, and what remains is emitting rows from the Edge Functions that will own those writes. An org admin editing roles and scopes with no audit row is still the gap an enterprise security review asks about first — it is now a wiring job rather than a design one.
3.6 · Performance envelope
| Concern | Position |
|---|---|
| Composite reads for multi-section screens | 📘 One composite RPC per screen. ⚠ Three or more parallel read RPCs saturate the connection pool |
| The largest tables this vertical creates | interaction_events, then leads. Both are subtree-queried, so index the materialized path |
| Public card render | 📘 Purge-on-write, never TTL. Cache-hit target >95% |
| Group rollups across 15 outlets | 🔎 A rollup over a subtree with 15 leaves is small. The risk is not row count, it is the per-row policy function — see §3.2 |
| Media | ⚠ The only line that scales without bound. Metered as a soft limit |
4 · What this means for sequencing
The one-line summary a developer should leave with
🔎 No dealership capability in this vertical requires a tenancy redesign. The four-layer model plus the workspace tree absorbs all of it as new tables inside an existing boundary. Three things are genuinely undecided and all three are on this page: how a customer is identified across tenants (§3.1), whether the subtree policy is indexable in practice (§3.2), and whether dedicated deployment is a product or a promise (deployment and isolation).
Related
- Deployment and isolation — the four deployment models and their commercial shape
- Persona feature map — what each capability looks like per login
- Operating model — what an interaction event must carry
- Index — 🧮 the measured build state