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:
- moved the lead’s primary pipeline/stage to the viewed pipeline, and
- 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:
- move the dragged-to stage onto the membership row (the board the user was on), and
- 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 defaultnewstage), and - resync
leads.statusto 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 (listLeadsapplies no pipeline filter), but are absent from every Kanban board — board fetches are pipeline-scoped (?pipelineId=…) and the scope isprimary 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’snewstage so they surface on the board.