Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c636e69
docs: design spec for duplicate contact merge revamp
bashar-qassis Aug 17, 2026
6b05d95
docs: make merge review permanent and add dismissed-pair barriers to …
bashar-qassis Aug 17, 2026
38cf080
docs: add user scenarios and delivery slicing to merge revamp spec
bashar-qassis Aug 17, 2026
c79bfb1
docs: fold ten walkthrough refinements into merge revamp spec
bashar-qassis Aug 17, 2026
f0bd190
docs: address design review findings; reject concern 8 with evidence
bashar-qassis Aug 17, 2026
c815c0a
docs: add slice 1 implementation plan for merge engine and resolver
bashar-qassis Aug 17, 2026
83d71bf
docs: add slice 2 and 3 implementation plans
bashar-qassis Aug 17, 2026
454cf17
feat(contacts): add merge field registry
bashar-qassis Aug 17, 2026
732a7db
feat(contacts): resolve scalar fields across merge members
bashar-qassis Aug 17, 2026
ffbae5e
feat(contacts): resolve arrays, associations, flags and immich state
bashar-qassis Aug 17, 2026
6c5b081
fix(contacts): make the id tie-break test exercise the actual tie-break
bashar-qassis Aug 17, 2026
477d108
feat(contacts): add N-way merge engine with locking and validation
bashar-qassis Aug 17, 2026
4c4394d
fix(contacts): tighten merge engine error contract and lock ordering
bashar-qassis Aug 17, 2026
66effa8
fix(contacts): remap debts, gifts, pets, tasks, conversations and imm…
bashar-qassis Aug 17, 2026
8e37913
fix(contacts): dedupe merge remap across the whole loser cluster, not…
bashar-qassis Aug 17, 2026
cc4bf12
feat(contacts): deduplicate fields, tags, photos and relationships af…
bashar-qassis Aug 17, 2026
4b78a77
fix(contacts): dedupe addresses and normalize contact-field dedupe key
bashar-qassis Aug 17, 2026
15f6502
fix(contacts): don't collapse addresses with an empty dedupe key
bashar-qassis Aug 17, 2026
cdd156f
feat(contacts): apply drop list, last talked to, job cancellation and…
bashar-qassis Aug 17, 2026
fffccd0
fix(contacts): keep loser-owned drops in trash, validate/audit tag dr…
bashar-qassis Aug 17, 2026
4932f87
fix(contacts): scope dropped-tag audit query to cluster members; docu…
bashar-qassis Aug 17, 2026
897cd99
feat(duplicates): resolve and repoint candidate pairs after a merge
bashar-qassis Aug 17, 2026
c9ac3b5
docs(spec): record ruling R15 — no DisplayNameRecomputeWorker enqueue…
bashar-qassis Aug 17, 2026
e586d29
fix(duplicates): guard unchecked/merged overlap, drop dead transactio…
bashar-qassis Aug 17, 2026
859ac08
refactor(contacts): route legacy merge paths through the new engine
bashar-qassis Aug 17, 2026
b47f3f1
fix(contacts): share survivor-value protection with the API merge path
bashar-qassis Aug 18, 2026
37d3399
refactor(contacts): flatten merge engine for credo strict
bashar-qassis Aug 18, 2026
4eb5aad
docs(plan): carry slice 1's drop-list and scope caveats into slice 2
bashar-qassis Aug 18, 2026
fb08a5b
fix(contacts): enforce D4 in the engine and allow merging needs_revie…
bashar-qassis Aug 18, 2026
bada4ad
docs(plan): note D4 coercion for slice 2's identity row
bashar-qassis Aug 18, 2026
f1a1602
docs(plan): warn slice 2 that immich fields are no longer engine-vali…
bashar-qassis Aug 18, 2026
c2b9ec2
fix(merge): resolve birthdate and first_met with their year-unknown f…
bashar-qassis Aug 26, 2026
1716d9a
fix(merge): carry year-unknown flags through legacy survivor protecti…
bashar-qassis Aug 26, 2026
2512f98
fix(merge): keep the strongest immich status when no member is linked
bashar-qassis Aug 26, 2026
4440e3d
fix(merge): exclude the survivor from inbound first_met_through remap…
bashar-qassis Aug 26, 2026
edc2fd2
fix(merge): re-date the surviving birthday reminder from the merged b…
bashar-qassis Aug 26, 2026
620dd56
fix(merge): repoint import records onto the survivor in the merge engine
bashar-qassis Aug 26, 2026
64693ca
fix: stop merge wizard defaults from clearing survivor-nil fields
bashar-qassis Aug 27, 2026
09553c8
test: assert highlight class instead of value text in merge wizard ra…
bashar-qassis Aug 27, 2026
2c14743
fix: collapse duplicate stay-in-touch reminders on merge
bashar-qassis Aug 27, 2026
3b0bebd
fix: repoint users.me_contact_id onto the merge survivor
bashar-qassis Aug 27, 2026
583dbc9
fix: return {:unknown_field, field} instead of raising KeyError
bashar-qassis Aug 27, 2026
7a60c91
docs: add {:unknown_field, field} to merge_cluster/4's doc
bashar-qassis Aug 27, 2026
e9a15c7
docs: add {:unknown_drop, key} to merge_cluster/4's doc
bashar-qassis Aug 27, 2026
2b0773b
fix: do not re-enqueue jobs for a deactivated birthday reminder
bashar-qassis Aug 27, 2026
ea6f6e4
test: pair gap-fill tests with the wizard path and add merge invariants
bashar-qassis Aug 27, 2026
cf86c09
test: disclose the invariant tests' blind spot and fix overclaiming c…
bashar-qassis Aug 27, 2026
9a083e4
chore: replace per-function Multi dialyzer escapes with a global :no_…
bashar-qassis Aug 27, 2026
d6f3d5f
docs: drop code-history comments from the merge engine
bashar-qassis Aug 27, 2026
0ec6438
docs: drop change-history narration from the merge PR's comments
bashar-qassis Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,422 changes: 2,422 additions & 0 deletions docs/superpowers/plans/2026-08-17-merge-revamp-slice-1-engine.md

Large diffs are not rendered by default.

2,387 changes: 2,387 additions & 0 deletions docs/superpowers/plans/2026-08-17-merge-revamp-slice-2-clusters-screen.md

Large diffs are not rendered by default.

719 changes: 719 additions & 0 deletions docs/superpowers/plans/2026-08-17-merge-revamp-slice-3-manual-merge.md

Large diffs are not rendered by default.

883 changes: 883 additions & 0 deletions docs/superpowers/specs/2026-08-17-duplicate-merge-revamp-design.md

Large diffs are not rendered by default.

247 changes: 247 additions & 0 deletions docs/superpowers/specs/2026-08-17-duplicate-merge-revamp-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# Duplicate Merge Revamp — Design Review

**Date:** 2026-08-17
**Reviews:** `2026-08-17-duplicate-merge-revamp-design.md`
**Status:** Open — items below are unresolved unless marked otherwise
**Verified against:** worktree `merge-revamp`, branch `worktree-merge-revamp`

Short version: a strong design. The best parts are genuinely good. It has one
under-specified core (cluster identity / route), a handful of concrete gaps that
will bite during implementation, and a slicing plan that does not hold up.

---

## What checks out

All three code citations in the design are exact:

| Design claim | Verified |
|---|---|
| `Contacts.merge_contacts/3` at `lib/kith/contacts.ex:1699` | ✅ exact line |
| `dismiss_candidates_for_contact/2` at `lib/kith_web/live/contact_live/merge.ex:174` | ✅ exact line |
| Worker skips `pending` **or** `dismissed` at `lib/kith/workers/duplicate_detection_worker.ex:70` | ✅ `where: d.status in ["pending", "dismissed"]` |

**Bug 1 is real.** The remap chain in `merge_contacts/3` covers notes,
`activity_contacts`, calls, life events, documents, photos, addresses, contact
fields, `contact_tags`, reminders and relationships. It omits `debts`, `gifts`,
`pets`, `tasks`, `conversations`, `immich_candidates`, `reminder_instances` and
`first_met_through` — all of which exist as associations on `Kith.Contacts.Contact`.

**Bug 2 is real and nasty.** Worker line 70 means a dismissal is permanent, and
the blanket post-merge dismissal wipes pairs the user never saw, with no rescan
to recover them.

**The negative-edge clustering rule is the strongest idea in the document.**
Plain union-find over pending edges would resurrect dismissed matches by
transitivity. §1 catches that, works the example, and accepts the residual cost
explicitly ("A skipped edge is invisible to the user until it resolves itself").

**The clique-over-*S* argument in §2 is likewise subtle and correct.** Detection
produces chains (A–B, B–C), so dismissing only the detected pairs leaves no
negative edge between A and C.

**The design undersells one win.** The current API merge path
(`lib/kith_web/controllers/api/contact_controller.ex:218`) writes **no** audit
entry at all — only the LiveView does. Moving audit into the engine fixes that
silently. Worth stating in §6 as a behaviour improvement.

---

## Blocking gaps

### 1. Cluster identity is never defined — and it is the route

§4 routes to `/contacts/duplicates/cluster/:id` while §1 insists clusters are
derived, not stored. §6 says the screen "accepts either a detected cluster key or
a bare contact id", disambiguating by whether the id belongs to a pending
cluster. So `:id` is presumably the minimum member contact id — never stated, and
load-bearing:

- Does the URL change when a new member joins and the min id shifts?
- Is the URL bookmarkable across detection runs?
- What happens when two tabs hold different derivations of the same cluster?

**Action:** define the cluster key explicitly in §1, and state its stability
guarantees.

### 2. `first_name` is `validate_required` — "Leave empty" cannot apply to every field

§3 says a resolved field "remains user-changeable, including an explicit 'Leave
empty'", and the resolution payload allows `:clear` for any field.
`Contact.update_changeset` requires `first_name`. Clearing it fails at step 2
with a changeset error the UI never predicted.

**Action:** add a non-clearable field set to the registry in §3, and have §4's
segmented control omit "Leave empty" for those rows.

### 3. `aliases` cannot be expressed in the `drop` payload

§4 puts aliases in the Contact details checklist alongside emails, phones,
addresses and tags; §3 gives them a dedupe key. But `aliases` is
`{:array, :string}` on `contacts` — elements have no ids — while `drop` is an id
map: `%{contact_fields: [88], addresses: [], tags: [12]}`.

**Action:** either move aliases to the scalar registry as a merged array value,
or give `drop` a non-id shape for them.

Related, smaller: `tags: [12]` is ambiguous. `contact_tags` is a bare join table
with no Ecto schema, so is `12` a `tag_id` or a join row id? Say which.

### 4. Outbound `first_met_through` self-reference

Bug 1 and D3 cover *inbound* references being repointed. Nothing covers:

- the survivor's own `first_met_through_id` pointing at a loser, or
- a loser's `first_met_through_id` pointing at the survivor.

After remap, that is a contact met through itself.

`first_met_through_id` is also not discussed as a mergeable scalar at all — nor
are `gender_id` / `currency_id` explicitly, though §1 name-drops `gender` and
`currency` as unexposed fields.

**Action:** give `belongs_to` ids their own rule in §3, including the self-
reference clear.

### 5. Score ties make greedy clustering nondeterministic

§1's worked example assumes distinct scores (0.90 vs 0.60). In practice
`compute_merged_score/1` emits a small fixed set of values:

| Signal | Base |
|---|---|
| email | 0.85 |
| phone | 0.75 |
| address | 0.60 |
| name | `similarity(display_name)` |

plus `0.05 × (signal_count - 1)`, rounded to 2 decimals. Ties are common, and
"sort pending pairs by score, descending" leaves the winner to arbitrary row
order — so which component an edge lands in can change between runs on identical
data.

**Action:** add a deterministic tiebreak (score, then `contact_id`, then
`duplicate_contact_id`) and state it. Otherwise the F2/F3 tests are flaky by
construction.

### 6. `merged`-status rows are invisible to both layers

§2's collision precedence keeps `merged` over `dismissed`. But §1 clusters over
`pending` + `dismissed` only, and the worker's dedupe check (line 70) also looks
only at `pending`/`dismissed`, with the unique index plus `on_conflict: :nothing`
behind it. Any `merged` row between two *active* contacts is therefore a
permanent silent block: never clustered, never re-detected, never surfaced.

No case was found where such a row survives — the loser is trashed, then
FK-cascaded on purge (`on_delete: :delete_all` on both contact FKs in
`20260321173108_create_duplicate_candidates.exs`).

**Action:** state the invariant ("a `merged` row always has at least one trashed
endpoint") rather than leaving it to be rediscovered.

### 7. No locking; the concurrency story is thinner than claimed

§2 says a concurrent edit "fails loudly instead of silently changing the
outcome" — true, since the submitted value would no longer be held by any member.
But:

- No recovery path is described. The user sees an abort and must redo every choice.
- Nothing prevents two users merging overlapping clusters simultaneously. Step 1's
"all active" check is not a lock.

**Action:** `SELECT ... FOR UPDATE` on the member rows in step 1, and a sentence
in §4 on what the user sees when validation aborts.

### 8. G2 is an acceptance criterion for a feature that does not exist

"Given a user without contact update permission…" — roles exist
(`admin` / `editor` / `viewer` on `Kith.Accounts.User`, `@valid_roles` at
`user.ex:52`), but `viewer` appears in the web layer **only** in
`kith_ui.ex` as a badge colour. There is no authorization enforcement anywhere.

G2 therefore either quietly expands this work to include building an authz layer,
or it gets dropped during implementation.

**Action:** decide which, now. If out of scope, delete G2.

---

## Smaller notes

- **§1 field count.** "Roughly thirty other contact fields" overstates it — the
castable field set is ~28 total, so about 20 are unexposed. The point stands;
the number does not.
- **§2 audit PII.** "every dropped record with its type, **value** and original
owner" writes emails, phone numbers and addresses into `audit_logs` metadata,
unbounded in size, outside the contact soft-delete/purge lifecycle. Probably
fine — deserves an acknowledging sentence rather than being a side effect of
"leave a trail".
- **§3 `deceased`.** "true if any member is marked deceased, taking the earliest
`deceased_at`" is undefined when the deceased member has a nil `deceased_at`
and another has a date.
- **§3 `is_archived`.** "false if any member is active" means a merge almost
always un-archives, since the column defaults to false. Presumably intended;
worth one line.
- **§1 the 500 cap.** "name matching already caps at 500 rows per scan" is true
(`LIMIT 500` on the name query, `duplicate_detection_worker.ex:107`), but the
email, phone and address queries are uncapped — total pending pairs are *not*
bounded by 500. In-memory union-find is still the right call; the justification
is wrong.
- **§1 greedy union cost.** The "would the component contain a dismissed pair"
check is O(|C₁|×|C₂|) per edge. Fine at this scale; worth stating.

---

## The slicing does not work

§8's suggested slicing claims four "independently shippable" slices. Two are not.

**Slice 1** says "the existing wizard and API adapt to the new functions". The
wizard passes `%{"first_name" => "non_survivor"}` — a per-field *chooser* keyed to
exactly two contacts. The new engine demands a complete resolution map with
concrete values. Adapting the three-step wizard to that is real work that slice 3
then deletes.

**Slice 2** ships a cluster *list* whose rows link to a cluster screen that does
not exist until slice 3.

**Proposed restructure:**

1. **Engine + resolver + bug fixes.** `MergeResolution`, `merge_cluster/4`,
expanded remap list, pair resolution, repointing. API adapted. The wizard left
calling a thin `merge_contacts/3` shim over the new engine — no wizard rework.
Covers D1–D8, F4, G1, G3, Bug 1, Bug 2.
2. **Clusters + screen, together.** `list_clusters/2`, negative-edge building,
trashed-member filtering, the index flip and the cluster LiveView in one
deploy. Covers A1–A4, B1–B9, C1–C5, F1–F3, F5–F6.
3. **Manual merge and cleanup.** Add-contact search, route changes, deleting
`ContactLive.Merge` and the dead `ContactLive.Duplicates`. Covers E1–E2.

---

## The one thing worth arguing with

**The non-goal on dismissal undo should be pulled into scope.**

§1 makes unchecking a checkbox a permanent, irreversible statement. §2 amplifies
it into a full clique of negative edges. §4's footer text is the only thing
standing between a user and silently destroying a correct future match — with no
dismissed-pairs view and no recovery short of `psql`.

The design names this risk honestly, which is to its credit. But the risk is
*created by this design*: today's dismissals are per-pair and far less
consequential. A read-only "Dismissed" list with a restore button is small next
to the rest of this work.

**Recommendation:** add it as a fourth slice rather than leaving it as "the
natural follow-up".

---

## Open questions for the next session

1. What is the cluster key, concretely, and how stable is it?
2. Non-clearable fields: just `first_name`, or is there a broader rule?
3. Aliases — scalar array merge, or extend `drop`?
4. Is G2 (permissions) in scope, or deleted?
5. Is the dismissal-undo slice in or out?
9 changes: 0 additions & 9 deletions lib/kith/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@ defmodule Kith.Accounts do
WebauthnCredential
}

# Suppress Ecto.Multi opaque type warnings (Dialyzer false positives)
@dialyzer [
{:nowarn_function, register_user: 1},
{:nowarn_function, confirm_user: 1},
{:nowarn_function, register_oauth_user: 4},
{:nowarn_function, accept_invitation: 2},
{:nowarn_function, do_remove_user: 1}
]

## Database getters

@doc """
Expand Down
14 changes: 14 additions & 0 deletions lib/kith/accounts/scope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,18 @@ defmodule Kith.Accounts.Scope do
end

def for_user(nil), do: nil

@doc """
Builds a full-privilege scope from a bare account id, for background and
adapter callers that have already established which account they are acting
on. `user` is nil, so callers needing an audit actor must supply one.

This is **not** an authorization scope: it grants access to `account_id`
without checking that anyone is entitled to it, so the id must be derived
from an already-authorized record (as `Kith.Contacts.merge_contacts/3` does,
reading it off a contact it just fetched) — never from user input.
"""
def system_for_account_id(account_id) do
%__MODULE__{user: nil, account: Kith.Repo.get!(Kith.Accounts.Account, account_id)}
end
end
7 changes: 0 additions & 7 deletions lib/kith/activities.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@ defmodule Kith.Activities do
alias Kith.Contacts.Contact
alias Kith.Repo

# Suppress Ecto.Multi opaque type warnings (Dialyzer false positives)
@dialyzer [
{:nowarn_function, create_activity: 4},
{:nowarn_function, update_activity: 4},
{:nowarn_function, create_call: 2}
]

## Activities

def list_activities(account_id, opts \\ []) do
Expand Down
Loading