Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksLead board DnD stage-reset — incident & data remediation

Lead board DnD stage-reset — incident & data remediation

Summary

Dragging a lead between stages on the lead board reset the card to its original column, and silently relocated the lead’s primary pipeline.

Code fix: PR #150 (fix(web): persist board DnD moves in secondary pipelines

  • fix(api): count secondary memberships in the empty-stage delete guard). This runbook covers the data remediation for leads corrupted before the fix shipped.

Root cause

The board drag handler PATCHed /leads/:id with { stageId }, which routes through updateLead → moveLeadToStage and always writes the primary organization_leads row (setting both stage_id and pipeline_id to the target stage’s pipeline). But the board reads the effective stage for the pipeline it shows — coalesce(membership.stage_id, primary.stage_id).

So when a lead was in the viewed pipeline via a secondary membership (lead_pipeline_memberships), a drag:

  1. moved the lead’s primary pipeline/stage to the viewed pipeline, and
  2. left the membership row’s stage untouched.

The membership stage then shadows the primary via coalesce, so the refetch returned the lead in its old stage (the “reset”), and the lead’s primary pipeline was silently relocated.

Corruption signature

A lead is corrupted iff its primary pipeline equals one of its own secondary memberships — a state only this bug can produce (addLeadToPipeline refuses to add a lead to its primary pipeline):

-- Audit: corrupted leads (READ ONLY). SELECT o.name AS org_name, ol.organization_id, ol.lead_id, ol.id AS organization_lead_id, ol.pipeline_id AS primary_pipeline_id, ol.stage_id AS primary_stage_id, m.id AS duplicate_membership_id, m.stage_id AS membership_stage_id FROM organization_leads ol JOIN lead_pipeline_memberships m ON m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id JOIN organizations o ON o.id = ol.organization_id ORDER BY o.name, ol.lead_id; -- Count per org. SELECT ol.organization_id, count(*) AS corrupted_leads FROM organization_leads ol JOIN lead_pipeline_memberships m ON m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id GROUP BY ol.organization_id ORDER BY corrupted_leads DESC;

2026-06-30: 3 corrupted leads, all in org Kinetics (b86fae6c-2826-4d62-b2e7-ba347a1911a1). The count grew 2 → 3 between detection and the #150 deploy (the bug kept firing until the fix shipped).

Repair

Do not simply delete the duplicate membership. The user’s intended action was a stage move within one board — they never meant to change the lead’s home pipeline. The bug wrote the dragged-to stage onto the primary row (organization_leads) instead of the membership row that board actually reads, which both shadowed the board and yanked the lead out of its home (default) pipeline. The faithful repair reproduces what the fixed code now does on a secondary-pipeline drag:

  1. move the dragged-to stage onto the membership row (the board the user was on), and
  2. restore the primary placement to the lead’s home — the org’s default pipeline at its original stage (reconstruct from lead_status_events; for the 2026-06-30 leads it was the default new stage), and
  3. resync leads.status to that restored stage’s category.

Deleting the membership instead would leave the lead living only in the secondary pipeline — it would vanish from the default board. That’s a side effect the user never intended.

Reconstruct each lead’s original home stage from history (READ ONLY):

SELECT ol.lead_id, e.to_stage_id, e.to_status, e.changed_at, (s.pipeline_id = def.id) AS in_default_pipeline FROM organization_leads ol JOIN lead_pipeline_memberships m ON m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id JOIN lead_pipelines def ON def.organization_id = ol.organization_id AND def.is_default = true JOIN lead_status_events e ON e.lead_id = ol.lead_id LEFT JOIN lead_pipeline_stages s ON s.id = e.to_stage_id WHERE ol.organization_id = :org ORDER BY ol.lead_id, e.changed_at;

The earliest in_default_pipeline = true event is the home stage. Then repair in a transaction (worked example: Kinetics, default pipeline 73447a2d-…, default new stage 4016f1a7-…). organization_leads has no updated_at column — don’t set it there.

BEGIN; -- 1) Move the dragged-to stage onto the membership row (uses the primary -- stage the bug wrote, before step 3 overwrites it). UPDATE lead_pipeline_memberships m SET stage_id = ol.stage_id, updated_at = now() FROM organization_leads ol WHERE m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id AND ol.organization_id = :org; -- expect N -- 2) Resync canonical status to the restored home stage's category -- (run before step 3, while the invariant still matches the rows). UPDATE leads SET status = 'new', updated_at = now() WHERE id IN ( SELECT ol.lead_id FROM organization_leads ol JOIN lead_pipeline_memberships m ON m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id WHERE ol.organization_id = :org ); -- expect N -- 3) Restore the primary placement to the default pipeline's home stage. UPDATE organization_leads ol SET pipeline_id = :default_pipeline_id, stage_id = :default_home_stage_id WHERE ol.organization_id = :org AND EXISTS ( SELECT 1 FROM lead_pipeline_memberships m WHERE m.organization_lead_id = ol.id AND m.pipeline_id = ol.pipeline_id ); -- expect N COMMIT; -- ROLLBACK if any step's count is unexpected.

Verify

Re-run the count audit (must be 0), and inspect the end state per lead:

SELECT ol.lead_id, l.status AS lead_status, pp.is_default AS primary_is_default, ps.category AS primary_stage_category, ms.category AS membership_stage_category, (pp.is_default AND ps.category = 'new' AND l.status = 'new' AND m.pipeline_id <> ol.pipeline_id) AS ok FROM organization_leads ol JOIN leads l ON l.id = ol.lead_id LEFT JOIN lead_pipelines pp ON pp.id = ol.pipeline_id LEFT JOIN lead_pipeline_stages ps ON ps.id = ol.stage_id LEFT JOIN lead_pipeline_memberships m ON m.organization_lead_id = ol.id LEFT JOIN lead_pipeline_stages ms ON ms.id = m.stage_id WHERE ol.organization_id = :org;

Each lead should read ok = true: back home on the default board at new, and showing in the dragged-to stage on the secondary board.

Out of scope / follow-ups

  • Unplaced leads (organization_leads.pipeline_id IS NULL). A separate, pre-existing population (legacy leads, or ingestion paths that create a lead without placing it on a pipeline). They do appear in the flat list view (listLeads applies no pipeline filter), but are absent from every Kanban board — board fetches are pipeline-scoped (?pipelineId=…) and the scope is primary pipeline = X OR has a membership in X, which a null-primary lead never matches. Not caused by this bug; track separately to backfill them onto the default pipeline’s new stage so they surface on the board.