Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0012: Lead status model (configurable pipelines) + channel-agnostic inbox

ADR-0012: Lead status model (configurable pipelines) + channel-agnostic inbox

Status: Accepted — shipped (PR #139, branch feat/lead-pipelines-inbox) Date: 2026-06-27 Context owner: Saar Related: ADR-0004 (org isolation), ADR-0011 (lead-funnel automations consume the status events), feature docs leads-crm, runbook inbox-email-inbound.

Context

Two needs landed together while building toward GoHighLevel parity for the alpha studio:

  1. Pipelines. The fixed five-state lead board (new/contacted/trial_booked/converted/lost) is too rigid — studios run custom sales stages (“No phone answer”, “Requested info”, “No-show”). But conversion, analytics and the lead-funnel automations (ADR-0011) all key off the lead_status enum, and we did not want to fork them.
  2. Inbox. Leads reply on many channels; the studio needs one thread per contact. WhatsApp is on its own branch and SMS is out of scope, but the model shouldn’t have to change when they arrive.

Decision

1. Stages carry a category; leads.status mirrors it

A pipeline is org-scoped, user-defined, ordered stages. Each stage maps to a semantic category = the legacy lead_status enum. The lead’s status is kept in sync with its current stage’s category on every move. So:

  • Studios get arbitrary stages; nothing downstream learns that stages exist — conversion, analytics, and automations keep reading leads.status.
  • stageId and status are one move (stageId wins; a bare status resolves to the matching-category stage). They can’t drift.
  • A default pipeline is seeded lazily per org (ensureDefaultPipeline), race-safe via a partial-unique “one default per org” index. Default stage names live in one shared constant (DEFAULT_LEAD_STAGE_NAMES) so the backend seed and the board’s “is this an un-renamed default → show a localized label” check never diverge. The funnel’s automated columns (“Awaiting payment”, “Didn’t finish signing up”, “Win-back”) resolve the same way, via the sibling SYSTEM_LEAD_STAGE_NAMES constant.

2. converted is reachable only through convert-to-member

Status transitions are otherwise free (it’s a manual sales process), with one server-enforced invariant: a move/status-set into a converted-category stage is rejected (400) unless the lead is already converted. The only path that sets converted is convertLead, which creates the membership — so a “converted” lead always has a membership behind it. Enforced in updateLead, so it holds for the board, the AI update tool, and any direct API call. The board UI routes a drag-to-Converted into the Convert dialog (and drag-to-Trial-booked into a date prompt) rather than letting the move silently fake state.

Why not a full state machine: sales is messy (reopen a lost lead, fix a mis-drag) and custom stages need free movement. The only transition with an irreversible side effect is converted (a real membership), so that is the only one guarded. trial_booked stays manual until a real lead-booking flow exists — we don’t auto-set a state we can’t verify.

3. Channel-agnostic inbox behind a ChannelAdapter registry

conversations + conversation_messages are channel-neutral; each channel implements a small ChannelAdapter (canSend / send) resolved by a ChannelRegistry. Two adapters ship: manual (staff note, always available) and email. Threads are idempotent on (org, channel, externalContactId); inbound is idempotent on (channel, externalMessageId).

Email specifically: outbound via Resend; inbound via Cloudflare Email Routing → a zero-dependency Email Worker that forwards the raw MIME to POST /webhooks/email/inbound (fail-closed on a shared secret, throttled). Threading uses a per-conversation Reply-To (contact+<conversationId>@<domain>) plus In-Reply-To/References. The MIME is parsed server-side (charset-aware, quoted-history stripped) with zero deps on the Worker side.

Adding WhatsApp/SMS later is “implement the adapter + write rows” — no model or API change.

Consequences

Positive

  • Custom funnels with zero blast radius on conversion/analytics/automations (the category mirror is the whole trick).
  • converted integrity is structural, enforced once, for every caller.
  • The inbox model + API are stable across future channels; email works end-to-end today.
  • Inbound email needs no third-party inbound-parse vendor — CF Routing + a tiny Worker + our own MIME parse.

Negative / costs

  • More schema: lead_pipelines, lead_pipeline_stages, conversations, conversation_messages + organization_leads.pipeline_id/stage_id + lead_status_events.from/to_stage_id (migrations 0069/0070).
  • Stage names are free-text org data → only un-renamed defaults can be localized per viewer; a renamed stage shows its stored text to everyone (acceptable, matches GHL).
  • Inbound notification delivery (push/email to staff) is not built — recordInbound emits conversation.inbound_received as the hook, logged for now.
  • Operational surface: a Cloudflare Email Worker + DNS + EMAIL_INBOUND_SECRET to run (documented in the runbook).

Alternatives considered

  1. Keep the fixed five statuses. Rejected — no custom-stage parity, the alpha studio’s whole workflow.
  2. Full transition state machine. Rejected — too rigid for sales; only converted needs guarding.
  3. Separate status column independent of stages. Rejected — two sources of truth drift; the category-mirror keeps one.
  4. Resend Inbound / a paid inbound-email parser. Rejected — CF Routing + a zero-dep Worker is free and keeps parsing in our code (and our control).
  5. A standalone messaging feature, not under leads. Rejected for now — the inbox is lead-centric today; it lives in the Leads tab. Member↔coach unification is a separate ticket.