Skip to content

Communications & Leads/CRM — design assessment and refinement ​

Assessment of the round-36 Claude Design output for the two new admin modules, from a product-owner and solution-architect position, before any backend exists. Pulled and measured 2026-08-22 against the live migration set and the design project's own modules (platform/contacts.js, platform/leads-core.js, the two *.spec.md files and leads-integration-assessment.md).

Architecture fit and the repo-side prerequisites are on Leads & CRM — architecture fit. This page is the design verdict and carries the refinement prompt.

THE HEADLINE, BEFORE THE CRITICISM

The designs are unusually strong, and two things in them should be adopted as platform doctrine rather than merely accepted. (1) "Every displayed fact declares its source" — Read from Meta · QR setu backend · Operator confirmed, with no fourth option allowed. That single rule removed a fake "send a test event" button, a masked-secret-with-copy control and an unreadable App Review status from an operations screen. (2) "Derived, never recorded" — no dismissible alerts, every chip computed from the row it describes. Both are the repo's own orders.payment_status-is-a-cache discipline arrived at independently, and they belong in the implementation contract.

The design also caught and fixed its own worst defect mid-round: a lead↔contact join by contactId: 'ct' + (i % 2380), then a replacement that resolved for 2% of rows, then a real fix measured at 1,524 of 1,840 leads. That is the right failure mode — found by measuring, not by reading.

Must fix before implementation ​

M1 · The industry vocabulary is a second, incompatible taxonomy — this is QRS-249 exactly ​

leads-core.js hardcodes 12 industry labels: Real Estate · Cafe and Restaurant · Salon and Spa · Electrician · Plumber · Tours and Travels · Purohit · Clinic · Retail · Tuition and Coaching · Event Services · Other.

The live industries table holds 14 snake_case keys: boutique · car_sales · dairy · direct_seller · electrician_plumber · festival_stall · kirana · photographer · real_estate · salon · sweet_shop · tiffin · tutor · yoga_fitness.

Roughly two align. Seven design labels have no database row at all; ten database industries are absent from the design; and the design splits Electrician/Plumber where the database has one electrician_plumber. Worse, the custom-field registry scopes off these labels — scope: ['Real Estate'], scope: ['Cafe and Restaurant'] — so "RERA number" attaches to a taxonomy that does not exist.

CLAUDE.md records the precedent in the same words: "onboarding/constants/domains.ts did exactly that and its invented ids disagreed with the database — 10 of 12 industry choices would have written the wrong industry (QRS-249)."

Fix, and it needs no new mechanism. A lead's industry references industries.key, fetched from get_industries(), never a hardcoded list. If a trade is genuinely missing (Purohit, Clinic), add an industries row — that is the ADR-0009 "new vertical is config" posture, and industries.status = 'private' already means "not taking new merchants" while staying live (QRS-803). One taxonomy, so a Purohit lead can convert into a Purohit merchant instead of landing on an industry that has no row.

M2 · Enum values are stored as display labels, so renaming a label breaks saved data ​

Measured in leads-core.js: stage: st.label — the label, not the id — and every saved segment predicate keys off it (filter: { stage: ['Follow-up pending'] }). Same for source, priority, industry, and owner, which is stored as a person's name string ('Vedaa Lahade').

Consequences: renaming "Follow-up pending" silently empties every segment using it; a person's name change orphans their assignments; and owner can never FK to users. This is the same class as QRS-802, where an internal key had leaked into a URL.

Fix: store keys (followup), project labels. owner_user_id uuid references users(id). The design already has the ids — STAGES carries { id, label, tone, help } — so this is using what is there.

M3 · Three counterparty identity models must be reconciled before parties exists ​

Full detail on the architecture fit page. In short: orders is phone-primary with a nullable user, conversations.consumer_user_id is NOT NULL with no phone at all, and the design's contact registry is phone-only. The design's premise — "a lead, a contact, a WhatsApp message and a Meta wamid share exactly one identifier and it is the number" — is true for orders and WhatsApp and false for chat. parties needs both phone_e164 and user_id, nullable, at least one present, with an explicit audited merge.

communications.spec.md declares ConsentRecord | phone + cat | append-only rows[[cat, granted/revoked, source, at]]. But contacts.js collapses it to one field: status ∈ active | not_marketable | suppressed | invalid, with marketable = status === 'active'.

Two problems. (a) Meta's categories have genuinely different consent semantics — transactional consent is the transaction, marketing needs explicit opt-in — so one flag cannot express "may receive utility, may not receive marketing", which is the single most common real state. (b) The enum mixes deliverability (invalid) with permission (not_marketable, suppressed), so an invalid number overwrites consent history.

Fix: the append-only per-category ledger the spec already specifies, plus a separate suppression record and a separate validity flag. ADR-0029's data model has this shape; keep it.

M5 · Three Meta facts in the screens are wrong, and one is a design decision resting on it ​

Verified against live Meta documentation on 2026-08-21/22 (two independent passes, assessment):

In the designVerifiedConsequence
Tier ladder 250 · 1K · 10K · 100K · unlimited, and the spec explicitly "corrected" 2,000 → 1,000250 → 2,000 → 10,000 → 100,000 → unlimited. The 1K tier was removed in the 2025-10-07 restructurethe screen "corrected" itself into the error; the Health tile shows a tier that no longer exists
Each phone number has its own tier and qualityMessaging limits moved to PER BUSINESS PORTFOLIO on 2025-10-07, "shared by all business phone numbers", and the per-number Flagged state "is no longer possible"a per-number tier column models a retired scope, and a send worker's token bucket "sized to the number's tier" would be sized to the wrong thing
"250 message templates per WABA", used to justify no pagination on the template registry ("the ceiling IS the page size")250 unverified; 6,000 once the portfolio is verified with an approved display name. (Meta's own docs are inconsistent here — one page still says a flat 250)QRSETU must verify to pass 250 msgs/day, at which point the ceiling is 6,000 and the no-pager decision breaks

M6 · RBAC does not exist, and the spec's biggest reuse claim is about the prototype ​

The Leads spec's headline is "The permission surface already exists… The role model is waiting for the module." True of RBAC.dc.html. In this repo: no roles, permissions, role_permissions, user_roles or platform_admins tables; no is_admin(); workspace_members.role_key is text default 'owner' held to five values by an interim CHECK (owner · admin · manager · member · viewer, 20260808210000_v2_production_hardening.sql:210-211), with no FK. ADR-0006 is Accepted and unimplemented.

This is the QRS-451 class — an absence proves nothing until you establish which project you are looking in — and it is the largest shared prerequisite for both modules. It is also bigger than either, so it should be its own wave.

M7 · No scheduler exists, and the only substrate is offline ​

Zero cron.schedule in the live migrations; public.outbox exists with no drain worker; the one recurring job runs on GitHub Actions, which is billing-blocked (QRS-790/791). The Communications spec names this as "the single largest unbuilt dependency" and is right. A campaign in running with no worker is a record that sits at running forever — the design says so in its own open questions. Audience import and fan-out make it larger.

M8 · Naming collisions, all four live in the incoming designs ​

campaign now has three meanings (ad campaigns · the registered campaigns feature = card offers · message campaigns); messages is already the in-app chat table; templates reaches four meanings and the admin panel would carry two differently-scoped "Templates" surfaces; and contact/customers/crm/party are four names for one concept. Resolutions are tabulated on the architecture fit page. ⚠ Do not reuse the campaigns feature key — it is taken, means card offers, and depends on store.

M9 · The search predicate contradicts the spec's own scale rule ​

leads-core.js's match() implements q as a substring scan across name + business + phone + email. The Leads spec's own scale section says: "Search on indexed columns only. Exact phone match, prefix match on name, never LIKE '%term%' across a large table." The module does exactly what the spec forbids. Decide the production search contract now (exact phone · prefix name/business · or a pg_trgm index accepted deliberately), because it determines an index.

M10 · A virtual contact carries a fabricated id ​

byPhone() returns an unregistered contact as { id: 'ct-' + hash, registered: false } so that "a lookup must never write" — a good invariant. But the id is indistinguishable in shape from a real one, and the design already shipped a bug of exactly this kind ("the virtual-contact fallback then hid it, because every lookup returned an object and contactKnown was unconditionally true"). In production a fabricated id handed to a write path creates an orphan FK. Fix: an unregistered lookup returns no id at all (or a distinctly-typed result), so passing it to a write is a compile error rather than a runtime orphan.

High-value, worth adding now ​

  • H1 · The enquiry → lead loop, which is the acquisition story. The Leads spec asks it as an open question and answers "it should". It should: the expertise archetype's entire output is "an enquiry", so an inbound Setu Card enquiry creating a lead is not a nicety, it is the archetype's fulfilment path. Model the enquiry as the lead's first activity, not a second table.
  • H2 · today(owner) is the best thing in the Leads module and should be the landing view. It returns overdue · dueToday · unassigned · untouched · noConsent. That is the "operationally self-explanatory" requirement satisfied by construction: an operator opens the module and sees their day. ⚠ Serve it from one composite RPC, per the repo's rule against ≥3 parallel read RPCs.
  • H3 · Keep STAGES[].help and surface it in the UI. Each stage already carries a plain-language explanation ("Interested — they asked a question back. This is the stage that predicts revenue."). Rendering those as tooltips answers the non-technical-operator requirement more cheaply than any amount of onboarding copy. Every status vocabulary in both modules should carry one, including Meta's own (131049 should never appear without "the person's weekly marketing limit, set by WhatsApp — retrying costs money and fails again").
  • H4 · Finish the declared parameter mapping (assessment item 2f). The round-36 note claims all of section 2 shipped, but coverage() still maps template params to lead fields with a hardcoded alias table (customer_name → name, …) while the registry already carries a mappable flag. The mechanism exists; the mapping is still guessed.
  • H5 · Make the handoff a row, not a browser key. contacts.js hands a segment to Communications via localStorage['qrsetu_comm_handoff'] with takeHandoff() deleting on read. The pattern is right — neither module imports the other, the intent is auditable, consumption is once-only — and it is exactly the outbox discipline. In production it is a row with a claimed_at, so a lost browser tab cannot lose an operator's intent.
  • H6 · Bulk operations over a predicate, with a job row. Both specs say it; the schema must permit it (saved predicate + job + idempotency key). "Assign all 40,000 real-estate leads to Priya" as 40,000 requests is an outage.
  • H7 · Retention as a control, defaulted. Flagged as unset in both designs. A lead that went nowhere three years ago is a DPDP liability; a message ledger grows monotonically at ~50k/day in the design's own sizing.
  • H8 · Masked phone by default with an audited reveal, enforced at the grant layer. The design has the display pattern; ADR-0014's column-level grants should carry it, because a 40,000-row CSV export is the module's highest-risk action.

Safely deferred ​

Inbound reply threading onto a lead · lead scoring ("or it is astrology" — the design's own words, and correct) · per-industry campaign playbooks · merchant self-serve lead management (a workspace-scoped grant of a feature that already exists) · lead CSV import (defer because it should reuse the audience importer rather than grow a second one) · prepaid communication credits (ADR-0030, blocked on Model A) · tenant-owned WhatsApp accounts · a raw webhook/event log surface.

Missing capabilities worth considering ​

  • A consent capture point that actually exists. The consent ledger's source vocabulary includes Setu Card opt-in, and the Communications assessment admits "the card footer opt-in the ledger references does not exist in the product." Either build it (it is a card manifest block and a write path) or remove the source. This is the only mechanism that generates marketing consent at scale, so it is arguably the highest-value missing item on this page.
  • Campaign cancellation and mid-flight halt. The state machine has running; nothing described stops a send that is going wrong. With a shared WABA that is the emergency brake.
  • A per-workspace send cap. Not a pricing lever — an availability control, because Meta's limit is portfolio-wide (ADR-0030 D4).
  • Duplicate-lead merge. duplicatesOf() surfaces same-phone leads; nothing merges them. Two operators working the same person is the most common real CRM failure.
  • Lead→workspace conversion. When a lead becomes a merchant, nothing links the lead to the workspace it produced, so the module can never report on what actually converted.

Unnecessary complexity / friction risks ​

  • Eight pipeline stages plus a separate priority plus next_action_at is three overlapping urgency signals. today() already derives urgency from dates; priority may be redundant. Watch it rather than removing it, but do not add a fourth.
  • Seven tabs in Communications is at the practical ceiling for a daily operator surface. The Alerts tab is the correct entry point and should be the default landing, not the seventh tab.
  • Two "Templates" surfaces in one admin panel (card templates, WhatsApp templates) will be opened wrongly. Rename both in the nav.
  • The Connection tab's asset table is close to developer-facing. Keep the Graph API version and ids as row metadata (as the assessment already decided) and resist re-adding anything a non-technical operator cannot act on.

Refinement prompt for Claude Design ​

Paste as-is. It asks only for changes justified above, and deliberately does not ask for new features.

Refine prototype/admin-panel/Communications.dc.html, prototype/admin-panel/Leads.dc.html, prototype/platform/leads-core.js and prototype/platform/contacts.js. Do not add new features. No em dashes in any copy, label or example.

1. Industry taxonomy. leads-core.js INDUSTRIES is a hardcoded list of 12 display labels that does not match QR setu's live industries reference table, which holds 14 snake_case keys: boutique, car_sales, dairy, direct_seller, electrician_plumber, festival_stall, kirana, photographer, real_estate, salon, sweet_shop, tiffin, tutor, yoga_fitness. Replace the hardcoded list with those keys plus display labels, model it as data fetched from a reference table (a platform/industries.js registry, same pattern as controls.js), and note in a comment that a missing trade is a new reference row and not a new literal. Re-scope the industry-specific custom fields (RERA number, FSSAI, chairs, seats, service radius) onto the correct keys. Where a trade in the current list has no key (Purohit, Clinic, Tours and Travels, Event Services, Retail, Cafe and Restaurant, Tuition and Coaching), add it to the registry as a row marked "not taking new merchants" rather than dropping it.

2. Keys, not labels, everywhere a value is stored. stage, source, priority and industry are currently stored as display strings and saved segment predicates key off them, so renaming a label would silently empty a segment. Store the id and render the label. owner is stored as a person's name: change it to an owner id with a display name resolved for presentation.

3. Consent is per category, and permission is not the same as deliverability. contacts.js collapses consent into one status of active, not_marketable, suppressed, invalid. Split it: an append-only consent ledger keyed on phone plus category (marketing, utility, authentication), a separate suppression record with its reason, and a separate number-validity flag. The common real state, "may receive utility but not marketing", must be representable. consentOf() should take a category.

4. Three Meta corrections. The messaging tier ladder is 250, 2,000, 10,000, 100,000, unlimited. There is no 1,000 tier; it was removed in the 2025-10-07 restructure, so the earlier correction of 2,000 to 1,000 was backwards. Messaging limits are now scoped to the business portfolio and shared by every phone number on it, not per number, and the per-number "Flagged" state no longer exists, so the Health tab must show one shared limit with per-number quality only. The template ceiling is 250 for an unverified portfolio and 6,000 once verified, so the template registry needs real pagination rather than relying on the ceiling as a page size.

5. An unregistered contact must not carry an id. byPhone() returns a virtual contact with a fabricated ct-<hash> id, which is indistinguishable from a real one and would create an orphan reference if written. Return the derived consent and display information with no id, so an unregistered contact cannot be passed to anything that writes.

6. Explain every status in place. STAGES already carries a plain-language help string per stage; surface it in the UI as a tooltip or an inline hint. Add the same one-line explanation to every other status vocabulary in both modules: campaign states, message states, contact status, suppression reasons, alert kinds, and Meta failure codes. A non-technical operator must never see a bare code: 131049 reads as "the person's weekly marketing limit, set by WhatsApp. Retrying costs money and fails again."

7. Finish the declared parameter mapping. coverage() still maps template parameters to lead fields with a hardcoded alias table while the field registry already carries a mappable flag. Drive the mapping from the registry, and let an operator set the mapping for a custom field.

8. Make the Leads landing view the day's work. today(owner) already returns overdue, due today, unassigned, untouched and no-consent. Make that the default view of the module, above the pipeline, because it is the only view most operators need most mornings.

9. Search must be honest about how it works. match()'s q does a substring scan across four fields, which the spec itself forbids at scale. Change it to exact phone match plus prefix match on name and business name, and say so in the placeholder ("Search by number, or the start of a name").

10. Add four missing operator capabilities. A campaign cancel / halt mid-send control with a confirm stating what happens to already-queued recipients. A merge action on duplicate leads found by duplicatesOf(). A lead-to-workspace link recorded when a lead is marked Won, so the module can report what converted. A retention row in the comms control group with a stated default, for both the message ledger and dead leads.

11. Default Communications to the Alerts tab, since "what needs me" is the reason an operator opens the module.

12. Rename the two template surfaces so they cannot be confused: the admin panel's existing card-template screen and the Communications WhatsApp-template tab must not both read "Templates".

Keep everything already right: source badges on every fact, derived-never-recorded alerts, no recharge control, mirrored-never-authored Meta status, marketing consent as an unremovable filter, permissive import with a strict send gate, the once-only handoff record, the per-dataset pagination strategy, and the eight comms control rows.

Shift-left readiness ​

After the refinement lands, these are settled enough to implement against: every status vocabulary with its operator explanation, the field registry and its types, the predicate language, the state machines, the pagination strategy per dataset, the source-of-truth badge per fact, and the permission matrix shape. Three things remain owner decisions and are called out rather than assumed: M6 (the RBAC wave), M7 (the scheduler), and whether the phone uniqueness index is global or per workspace (the Leads spec's own open question 2 — the design implements "surface duplicates, prevent nothing", which is a reasonable default but is currently a decision made by code).