Appearance
ADR-0037 · QR setu's own sales pipeline is a separate, operator-only store
Status: 🟡 Proposed · Raised: 2026-09-28, when the owner added Leads & CRM to the Admin Panel MVP · Depends on: ADR-0035 (operators, the permission registry, the audit writer, masking and reveal), ADR-0032 (the phone is a credential) and ADR-0031 (placement) · Tracker:QRS-1427 (this decision) · QRS-1411 (import and the Won link) · QRS-874 (industries) · QRS-668 (the merchant enquiry-form row, tracker.md:887) · QRS-873 (the contact registry) · QRS-1404 (programme)
The decision, in one sentence
The prospective merchants QR setu's own team is selling to live in dedicated tables that only operators can reach, never in a merchant's lead rows; the MVP runs the pipeline and sends nothing.
Context
The Admin Panel's Leads & CRM desk is the platform's own sales CRM: the Operations and Sales pipeline of prospective merchants, not a merchant's enquiries (the design, prototype/admin-panel/Leads.dc.html with prototype/platform/leads-core.js).
| Measured | Evidence |
|---|---|
| Nothing exists for it. No live migration creates a leads table, no Edge Function handles one, and no code names one | supabase/migrations/*.sql, supabase/functions/, packages/, apps/ searched 2026-09-28: zero hits |
| The only written position calls it "a different subject", to be held in "a distinct workspace or a distinct table", and "not the same rows with a flag" | documentation/portal/communications/leads-and-crm.md:169-172 |
The only approved WhatsApp templates in the repo are three translations of qrsetu_otp, category AUTHENTICATION | supabase/migrations/20260901140000_v2_whatsapp_registry_seed.sql:78-89 |
| The consent, campaign and inbound-message tables are deliberately absent | 20260901120000_v2_communications_whatsapp.sql:22-32 |
| The webhook processes delivery statuses and records template events; inbound messages, and so any STOP, are not handled | supabase/functions/whatsapp-webhook/helpers.ts:37-46, :61-65; its README.md:74-75 |
A message's correlation_type has no lead | 20260901120000_v2_communications_whatsapp.sql:228 |
The design has 8 stages, 3 of them terminal; won means "Signed up and card published" | leads-core.js:38-47, :55 |
8 lost reasons, each marked revivable or not; refused "Suppresses the contact" | leads-core.js:637-646 |
5 roles, 11 CRM actions, and a record scope of own or all | leads-core.js:918-930 |
| Lead owners are a hardcoded list of names, not staff | leads-core.js:57 |
Assignment is a mode (manual, by industry, by area, else round robin) over two hardcoded maps: SPECIALISM, owner to industry labels, and AREA_DESK, owner to a list of Pune localities | leads-core.js:830-842 |
The platform's location data is states and cities; a sub-city locality is "a later table" | 20260817170000_v2_location_reference_data.sql:72, :123, :151 |
The merchant's own pipeline has 6 stages: new, contacted, interested, session, converted, inactive | documentation/portal/dev-tracker/tracker.md:887: QRS-668, the merchant enquiry-form row (the id is duplicated; :896 is an unrelated defect) |
| The database holds 14 industry keys; the design hardcodes its own labels | 20260808120000_v2_taxonomy.sql:237-289; QRS-874 |
Design line numbers are those of the copy pulled from the Claude Design project "QR setu prototype" on 2026-09-28.
Decision
D1 · Dedicated operator-only tables, in public
Table (names illustrative until the migration passes check:naming) | Holds |
|---|---|
platform_leads | stage key, owner_user_id (an operator, D6), industry_key (→ industries.key), source, priority, next_action_at, stage_since, lost reason, fields (jsonb with a GIN index), the prospect's contact details, converted_user_id and converted_workspace_id (both nullable, D4), a version |
platform_lead_stage_events | append-only: from, to, the call outcome, by whom, when. The funnel, stall and velocity figures derive from it |
platform_lead_activities | append-only: call, note, reveal, assignment, import |
platform_lead_fields | the custom-field registry: type, when it shows, which industries it applies to, whether it can be mapped on import |
platform_lead_assignment_rules | the assignment mode (manual, by industry, by area, or round robin), and per operator the industries they take (keys from industries) and the areas their desk covers (keys from the platform's location data), each row with a version |
| CRM policy values | seeded and shown read-only; editing them belongs to Mission Control, outside the MVP |
- Never merchant rows, and never a flag. These tables are kept apart from any future merchant leads table (owner decision Q8).
- Placement:
public, because ADR-0031's context vocabulary is closed (lines 149-151). Revoked fromanonandauthenticated, RLS enabled with no policy, reached only through definer functions that checkoperator_can('crm', action)with the caller's own token (ADR-0035 D7). - The
ownrecord scope filters at the source. A Sales Executive's read returns their own leads and no others, inside the SQL, never after it in the UI (ADR-0035 D2; the design'sscoped(),leads-core.js:927-930). - Contact details are masked by default, and a reveal is audited, by the same mechanism as the Users desk (ADR-0035 D10). A reveal also writes a lead activity.
- Deleting an account never deletes sales history.
converted_user_idandconverted_workspace_idareon delete set null, so an erasure of the converted account neither blocks nor is blocked by the pipeline. - Assignment rules are data, never code. The design's
SPECIALISMandAREA_DESKmaps hardcode names, industry labels and a list of Pune localities (leads-core.js:830-833), the QRS-874 class. In the rules table an owner is an operator, an industry is anindustrieskey, and an area is a key of the platform's location data. That data holdsstatesandcitiestoday (20260817170000_v2_location_reference_data.sql:72,:123) and calls a sub-city locality "a later table" (:151), so a locality-level desk waits for that table and is never a typed list. The dry run is a read and the run a bounded write (planAssignandrunAssign,leads-core.js:843-847), each run audited.
D2 · The pipeline only: no WhatsApp sending in the MVP
Owner decision Q6. Sending would need four things that do not exist: approved templates beyond OTP, a consent store, a suppression list, and inbound STOP handling (the Context table). One platform-owned number sends for everyone (20260901140000_v2_whatsapp_registry_seed.sql:37-59; QR setu is the single sender of record, ADR-0029 line 72), so a quality drop caused by unsolicited sales messages would harm every merchant. Send WhatsApp, Ask for consent, segments handed to campaigns, and campaign readiness render the design's reserved state, naming the store each one waits for: availability false, never an upgrade prompt and never absent (R-05; the state's design is QRS-1409).
D3 · refused is recorded, and flagged for the future suppression list
A lead lost as refused records that reason and is flagged for the suppression list Communications will build, so no refusal is lost in the meantime. That list will be keyed on the phone, not on the lead (communications/leads-and-crm.md:74-76; the planned consent table is phone-keyed, 20260901120000_v2_communications_whatsapp.sql:24-26). Nothing in the MVP reads the flag, because nothing in the MVP sends.
D4 · The Won link is manual and confirmed, never matched by phone
An operator marks a lead Won by searching for the user or workspace and confirming the link. The link writes a lead activity and an audit row. It is never matched automatically by phone: ADR-0032 makes the phone a credential, never an address or a key (D1, line 42), and an automatic join on it would use the phone as exactly that. The design's option of auto-matching on sign-up with a confirmation is not adopted.
D5 · The admin's eight stages are not the merchant's six
The admin pipeline keeps its own eight stage keys: new, contacted, interested, followup, demo, won, lost, unqualified (leads-core.js:38-47). The merchant pipeline's six (QRS-668, the merchant enquiry-form row, tracker.md:887) share three of those words with different meanings, so the two never share a stage table, a CHECK or a label map; that shared-word collision is the QRS-249 class. Stages are stored as keys, and their labels come from @qrsetu/i18n.
⚠ The design keys its stall thresholds by label, not by stage key (leads-core.js:650). The implementation keys everything by stage key, and the mismatch goes to the design-gap loop.
D6 · Owners are operators, and industries are keys
owner_user_idreferences the operator record (ADR-0035 D1), never a free-text name (the design's hardcoded list,leads-core.js:57). An owner must hold a CRM role.- Deactivating an operator who owns leads reassigns those leads inside the same flow, before the deactivation completes (
design-system/admin-panel-round-1-prompt.md:170-171). No lead is ever left owned by a deactivated operator, so nothing surfaces for reassignment afterwards. industry_keyreferencesindustries.key, never a hardcoded list (QRS-874), and an assignment area references the platform's location data the same way (D1).
D7 · Duplicates are found lead to lead, never lead to account
Duplicate detection compares a new lead's phone with the phones of other leads, which are records this pipeline owns, and flags a likely duplicate without merging it. It never compares a lead's phone with a platform account; that would be the phone-as-key join D4 refuses.
Alternatives rejected
| Option | Why not |
|---|---|
| Merchant leads rows with a platform flag | "not the same rows with a flag" (communications/leads-and-crm.md:169-172): the party is a prospective merchant, not a merchant's customer |
| A QR setu workspace holding the sales team's leads | Operators never hold workspace memberships (the proposal's INV-1 and its provisioning guard, proposal lines 218-221 and 407-440); a QR setu workspace would make staff members of the tenant plane |
| Build the contact and consent registry first (QRS-873) | It is the right long-term home for the design's rule, "A Lead REFERENCES a Contact, and consent lives only on the Contact" (leads-core.js:3-4), but it would block the pipeline on the Communications design. The MVP stores the prospect's contact on the lead, stores no consent anywhere, and moves the contact to the registry by expand-contract when it lands |
| Link Won by an automatic phone match | The phone is a credential, never a key (ADR-0032 D1) |
| Send through the existing WhatsApp sender with a marketing template | No approved template, no consent store, no STOP handling (the Context table) |
Consequences
- The Leads desk's reserved states (send, consent, segments, campaign readiness, CRM policy editing) are designed in the correction round, not invented in code.
- Import (upload, map, validate, preview, commit) is designed inside Leads (QRS-1411) and bounded per call, because there are no background jobs (QRS-885). Export neutralises CSV formulas (ADR-0035 D10).
- Retention under the DPDP Act is open. Prospects' names and phone numbers are personal data; the retention period, and what happens to a lead after it is lost, are decided before any real lead is imported.
- No core entity is altered. The two conversion references point at
usersandworkspaceswithon delete set null, and neither table changes shape. - Scale beyond the MVP: half a million leads needs bulk operations that run without a person waiting, which needs the scheduler decision (QRS-885).
- Tests owed: a Sales Executive's list count equals the count of their own leads; a reveal writes an activity; an import dry run and an assignment dry run change nothing; a Won link names the operator who confirmed it; a deactivation cannot complete while the operator still owns a lead.
Evidence
Measured for this ADR, 2026-09-28:
| Claim | Where |
|---|---|
| No leads table, Edge Function or code exists | supabase/migrations/*.sql, supabase/functions/, packages/, apps/: zero hits for a leads table or a platform lead name |
| The platform pipeline is a different subject | documentation/portal/communications/leads-and-crm.md:169-172 |
| Consent will be keyed on the phone | documentation/portal/communications/leads-and-crm.md:74-76; supabase/migrations/20260901120000_v2_communications_whatsapp.sql:24-26 |
| Sending is impossible today | 20260901140000_v2_whatsapp_registry_seed.sql:78-89; 20260901120000_v2_communications_whatsapp.sql:22-32, :228; supabase/functions/whatsapp-webhook/helpers.ts:37-46, README.md:74 |
| The industry keys | supabase/migrations/20260808120000_v2_taxonomy.sql:237-289 |
| The merchant's six stages | documentation/portal/dev-tracker/tracker.md:887 (QRS-668, the merchant enquiry-form row; the id also heads an unrelated row at :896) |
| The platform's location data, and the locality table it defers | supabase/migrations/20260817170000_v2_location_reference_data.sql:72, :123, :151 |
| Deactivation reassigns leads in the same flow | documentation/portal/design-system/admin-panel-round-1-prompt.md:170-171 |
| The design's contact rule, stages, lost reasons, stall table, assignment rules, roles and owners | prototype/platform/leads-core.js:3-4, :38-57, :637-650, :830-847, :918-930 |
Expected, unverified:
- The live template registry on either project matches the repo's seed. Templates can change on Meta's side, and the webhook records such a change without applying it to the registry (
supabase/functions/whatsapp-webhook/index.ts:185-203, QRS-1425).