feat(parties): merge with survivor choice and 30-day undo (#2175)

Phase 1d. merge_parties soft-merges live parties into a survivor
(merged_into + archived_at), unions alias keys and copies an org number
the survivor lacks; facts, identities and role links stay where they are
and readers resolve through canonical_party_id(). undo_party_merge
restores the merged rows and the survivor snapshot within 30 days and
logs a split decision; a second undo and other companies are refused.
pg-real tests cover merge, undo, the window, chained merges and every
rejection path.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 18:18:35 +02:00
committed by GitHub
parent fc04578818
commit c0ecf2fa3b
2 changed files with 328 additions and 0 deletions
@@ -0,0 +1,193 @@
-- Parties, phase 1d: merge with survivor choice, and undo within 30 days.
--
-- A merge is soft: the merged party rows stay, with merged_into pointing at
-- the survivor and archived_at set. Facts, identities and role links keep
-- their party_id; readers resolve the canonical party through
-- canonical_party_id(). That is what makes undo a plain restore rather than
-- a reconstruction, which the July research found is where Attio, Pennylane
-- and Ramp lose data (merges irreversible, a paid undo market exists).
--
-- What the survivor gains at merge time: the union of alias keys (so the
-- suggestion pipeline attaches future keys to the survivor) and an org
-- number if it had none. Both are snapshotted in the decision and restored
-- on undo.
CREATE OR REPLACE FUNCTION public.canonical_party_id(p_party_id uuid)
RETURNS uuid
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
WITH RECURSIVE chain AS (
SELECT p.id, p.merged_into, 1 AS depth
FROM public.parties p WHERE p.id = p_party_id
UNION ALL
SELECT p.id, p.merged_into, c.depth + 1
FROM chain c JOIN public.parties p ON p.id = c.merged_into
WHERE c.merged_into IS NOT NULL AND c.depth < 16
)
SELECT id FROM chain ORDER BY depth DESC LIMIT 1;
$$;
REVOKE ALL ON FUNCTION public.canonical_party_id(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.canonical_party_id(uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.canonical_party_id(uuid) IS
'Follows merged_into to the surviving party (at most 16 hops). Returns the input id for a live party.';
-- ── Merge ───────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.merge_parties(
p_company_id uuid,
p_user_id uuid,
p_survivor uuid,
p_merged uuid[],
p_note text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_survivor public.parties%ROWTYPE;
v_ids uuid[];
v_before jsonb;
v_alias text[];
v_org text;
v_decision uuid;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'merge_parties: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
SELECT * INTO v_survivor FROM public.parties
WHERE id = p_survivor AND company_id = p_company_id AND merged_into IS NULL
FOR UPDATE;
IF v_survivor.id IS NULL THEN
RAISE EXCEPTION 'merge_parties: survivor is not a live party of this company' USING ERRCODE = '23503';
END IF;
-- Only live parties of this company, never the survivor, each once.
SELECT coalesce(array_agg(p.id ORDER BY p.created_at, p.id), '{}') INTO v_ids
FROM public.parties p
WHERE p.company_id = p_company_id AND p.id = ANY(p_merged) AND p.id <> p_survivor AND p.merged_into IS NULL;
IF coalesce(array_length(v_ids, 1), 0) <> (SELECT count(DISTINCT x) FROM unnest(p_merged) AS x WHERE x <> p_survivor) THEN
RAISE EXCEPTION 'merge_parties: every merged id must be a live party of this company' USING ERRCODE = '23503';
END IF;
IF coalesce(array_length(v_ids, 1), 0) = 0 THEN
RAISE EXCEPTION 'merge_parties: nothing to merge' USING ERRCODE = '22023';
END IF;
PERFORM 1 FROM public.parties WHERE id = ANY(v_ids) FOR UPDATE;
SELECT ARRAY(SELECT DISTINCT x FROM (
SELECT unnest(v_survivor.alias_keys) AS x
UNION ALL
SELECT unnest(p.alias_keys) FROM public.parties p WHERE p.id = ANY(v_ids)
) a),
coalesce(v_survivor.org_number, (
SELECT p.org_number FROM public.parties p
WHERE p.id = ANY(v_ids) AND p.org_number IS NOT NULL
ORDER BY p.created_at, p.id LIMIT 1))
INTO v_alias, v_org;
v_before := jsonb_build_object(
'survivor', jsonb_build_object('id', v_survivor.id, 'alias_keys', to_jsonb(v_survivor.alias_keys), 'org_number', v_survivor.org_number),
'merged', (SELECT jsonb_agg(jsonb_build_object('id', p.id, 'display_name', p.display_name, 'status', p.status, 'archived_at', p.archived_at) ORDER BY p.created_at, p.id)
FROM public.parties p WHERE p.id = ANY(v_ids))
);
UPDATE public.parties
SET merged_into = p_survivor, archived_at = coalesce(archived_at, now())
WHERE id = ANY(v_ids);
UPDATE public.parties
SET alias_keys = v_alias, org_number = v_org
WHERE id = p_survivor;
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
VALUES (p_survivor, p_company_id, p_user_id, 'merge', v_before,
jsonb_build_object('survivor', jsonb_build_object('id', p_survivor, 'alias_keys', to_jsonb(v_alias), 'org_number', v_org), 'merged', to_jsonb(v_ids)),
p_note)
RETURNING id INTO v_decision;
RETURN v_decision;
END;
$$;
REVOKE ALL ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) TO authenticated, service_role;
COMMENT ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) IS
'Soft-merges live parties into a survivor (merged_into + archived_at), unions alias keys, copies an org number the survivor lacks. Returns the merge decision id for undo_party_merge.';
-- ── Undo ────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.undo_party_merge(
p_company_id uuid,
p_user_id uuid,
p_decision_id uuid
)
RETURNS integer
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_d public.party_decisions%ROWTYPE;
v_ids uuid[];
v_restored integer;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'undo_party_merge: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
SELECT * INTO v_d FROM public.party_decisions
WHERE id = p_decision_id AND company_id = p_company_id AND kind = 'merge'
FOR UPDATE;
IF v_d.id IS NULL THEN
RAISE EXCEPTION 'undo_party_merge: no merge decision % in this company', p_decision_id USING ERRCODE = '23503';
END IF;
IF v_d.created_at < now() - interval '30 days' THEN
RAISE EXCEPTION 'undo_party_merge: the 30-day undo window has passed' USING ERRCODE = '22023';
END IF;
IF EXISTS (SELECT 1 FROM public.party_decisions u
WHERE u.company_id = p_company_id AND u.kind = 'split' AND u.before->>'decision_id' = p_decision_id::text) THEN
RAISE EXCEPTION 'undo_party_merge: merge % is already undone', p_decision_id USING ERRCODE = '22023';
END IF;
v_ids := ARRAY(SELECT (x)::uuid FROM jsonb_array_elements_text(v_d.after->'merged') AS x);
-- Survivor first: it may carry an org number copied from a merged row, and
-- that row cannot become live again while the survivor still holds it.
UPDATE public.parties
SET alias_keys = ARRAY(SELECT jsonb_array_elements_text(v_d.before->'survivor'->'alias_keys')),
org_number = nullif(v_d.before->'survivor'->>'org_number', '')
WHERE id = v_d.party_id AND company_id = p_company_id;
-- Only rows still merged into this survivor come back; a party merged
-- onward since then belongs to a later decision.
WITH restored AS (
UPDATE public.parties p
SET merged_into = NULL,
archived_at = (SELECT nullif(m->>'archived_at', '')::timestamptz
FROM jsonb_array_elements(v_d.before->'merged') m WHERE (m->>'id')::uuid = p.id)
WHERE p.id = ANY(v_ids) AND p.company_id = p_company_id AND p.merged_into = v_d.party_id
RETURNING p.id
)
SELECT count(*) INTO v_restored FROM restored;
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
VALUES (v_d.party_id, p_company_id, p_user_id, 'split',
jsonb_build_object('decision_id', p_decision_id, 'merged', to_jsonb(v_ids)),
jsonb_build_object('restored', v_restored),
'undo merge');
RETURN v_restored;
END;
$$;
REVOKE ALL ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) IS
'Restores the parties merged by one merge decision within 30 days: clears merged_into, restores the survivor''s alias keys and org number, logs a split decision. Returns the number of restored parties.';
NOTIFY pgrst, 'reload schema';