Skip to content

Merge revamp slice 2: clusters and the single-screen merge - #28

Merged
bashar-qassis merged 28 commits into
worktree-merge-revampfrom
merge-revamp-slice-2
Aug 28, 2026
Merged

bashar-qassis merged 28 commits into
worktree-merge-revampfrom
merge-revamp-slice-2

Conversation

@bashar-qassis

@bashar-qassis bashar-qassis commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

Slice 2 of the duplicate-contact merge revamp. Replaces the pairwise duplicates queue and the three-step merge wizard with a cluster list and a single-screen cluster merge, built on slice 1's resolver and engine.

Stacked on #27 — review that one first; this diff sits on top of it.

Spec: docs/superpowers/specs/2026-08-17-duplicate-merge-revamp-design.md
Plan: docs/superpowers/plans/2026-08-17-merge-revamp-slice-2-clusters-screen.md

What's here

  • Clusters, derived not stored. list_clusters/2 computes components from candidate pairs on every read, so they cannot drift from the pairs that justify them. The build is greedy: pending pairs sorted by {-score, contact_id, duplicate_contact_id}, and a pair unions its endpoints only if the result contains no dismissed pair — dismissals are negative edges, so a match the user rejected can never reappear by transitivity through a third contact.
  • MergeSummary — the multi-valued records and history counts the screen shows, with duplicates marked rather than removed so the screen can show what the merge will collapse.
  • The cluster merge screen at /contacts/duplicates/cluster/:id — member strip with checkboxes and a primary chip, an Identity section where every mergeable field is visible and editable in place, Contact details as prunable checklists, history as counts, and one Immich row listing linked members by name.
  • The duplicates index lists clusters instead of pairs: four duplicates of one person are one entry, not six rows.

Spec scenarios

A1–A6, B1–B10, C1–C5, F1–F3, F5–F6, G2. The two end-to-end scenarios §7 asks for by name — the handled-cluster scenario and the two-people scenario — are their own tests in test/kith/duplicate_merge_scenarios_test.exs, driven through the real detection worker, resolver and engine rather than hand-placed state.

Notable decisions

Each was a conflict between the plan and either the spec or the code; the spec won.

  • The drop list expands to every row backing a dropped value. The engine deduplicates before applying drops, so naming only the row the user clicked can leave an equivalent the engine keeps — and the excluded value silently returns. MergeSummary carries a dedupe-equivalence key for exactly this; the leak is cross-owner (clicked row on a loser, equivalent on the survivor) and there is a test constructed to fail without the expansion.
  • Not-null columns are non-clearable. MergeFields.non_clearable/0 now unions the changeset's validate_required list with the NOT NULL columns read from information_schema, because "Leave empty" on birthdate_year_unknown produced a 23502 out of the engine, outside its documented error contract. Derived rather than hardcoded, so a future null: false column is covered on its own; cached in :persistent_term, so a nullability migration needs a restart to take effect.
  • The Immich row submits a chosen member, never free-form field values, and "Not linked" writes immich_status: "unlinked" rather than nil — slice 1 exempts that group from held-by-member validation, so a nil would hit the CHECK constraint as an unhandled error.
  • Member ids are filtered out of the first_met_through_id row, since the engine coerces a self-reference to empty and the user would otherwise pick a value that silently vanishes.
  • Policy flags render read-only with the rule that produced them, so un-archiving a survivor is visible rather than silent.
  • Clustering is deterministic. Scores come from a small fixed set, so ties are the common case; the sort breaks them on contact ids, and the test forces two tied edges to compete rather than passing on insertion order.

Verification

  • 1358 tests, 0 failures. mix precommit green (compile --warnings-as-errors, deps.unlock --unused, format, credo --strict, test).
  • Every fix landed with a test that fails without it; most were verified by reverting the fix and confirming the failure.

Before merging

  • Manual check: open a real four-member cluster and confirm the Identity section opens itself while Contact details stays folded.
  • CI does not run on this PR — .github/workflows/ci.yml triggers only on PRs targeting main/master, and this one targets the slice 1 branch. Only the bottom of the stack is gated.
  • Known and parked: the nav badge derives clusters on every authenticated mount (cheaper per derivation now, but dismissed rows accumulate forever); a first_met_through_id row mixing a member and an outsider renders an unhighlighted candidate that submits as cleared.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DCMNiVE7SKJeMusAn7DiMd

@bashar-qassis bashar-qassis changed the title merge revamp slice 2 Merge revamp slice 2: clusters and the single-screen merge Aug 19, 2026
…terminism test

- to_clusters/3 now scopes the contact lookup by account_id (defence in
  depth; the ids it reads were already account-scoped upstream)
- move the >= 2 member-count gate to after contact resolution so it can't
  disagree with the reject(&is_nil/1) that follows it
- the determinism test previously asserted nothing about the id tiebreak
  (no dismissal meant both tied edges unioned regardless of order); it now
  dismisses a third pair so the tied edges compete, and inserts them in
  reverse id order so DB row order can't accidentally produce the result
- add cross-account isolation, cluster_count/1, and key-ascending tiebreak
  tests
…collapsing

Mirror Kith.Contacts.Merge's dedupe_owned_step/2 blank-key guard: an address
with neither line1 nor postal_code must not be treated as a duplicate of
another blank-key address, since they can differ by city/region/country.
…page

blocked?/4 was scanning every dismissed candidate on each union check,
making cluster derivation O(pending x dismissed); dismissed rows are
never deleted, so that term grows for the account's lifetime while
pending stays small. Index dismissed pairs as contact_id => MapSet of
partner ids and walk the smaller of the two merging groups instead.

Also scope the candidate query's contact joins to the account so
resolvability is enforced in SQL, give cluster_count/1 a contacts-free
counting path, add list_clusters_page/2 so the duplicates page derives
clusters once instead of twice, and fix a has-more false positive when
the total lands exactly on a page boundary.

Test gaps closed: load_more_duplicates exercised at the LiveView level,
and a dismissed pair verified to keep its two contacts out of the same
rendered cluster.
Dialyzer failed on this branch with two unknown_type errors:
`Kith.Contacts.Contact.t/0` and `Kith.Contacts.DuplicateCandidate.t/0`.
`Kith.DuplicateDetection.Cluster`'s `@type t` names both, but an Ecto
schema does not define `t/0` on its own, so neither existed.

Declared as `%__MODULE__{}`, matching `Kith.Accounts.User`.
Merging the base branch brought in MergeFields.coupled_fields/0, which moved
birthdate_year_unknown and first_met_year_unknown out of choice_fields/0. They
are derived from whichever member supplied their date, so the cluster merge
screen renders no control for them and two tests written against the old
independent-flag shape failed.

Replace them with assertions over the coupled design: flags get no row and are
unreachable as a clear target, and the not-null guard now runs over every
non-clearable choice field with a non-vacuity check.
Three review findings on the cluster merge screen, all reachable from ordinary
use.

upsert_dismissed/4 read the candidate row and then inserted it. The detection
worker writes the same pairs, so a scan landing between the two raised on the
[:account_id, :contact_id, :duplicate_contact_id] unique index, rolled the
whole clique back and took the LiveView down with it. The insert now carries
ON CONFLICT DO UPDATE guarded on status != "merged", which preserves the same
exemption the read path applies. A guard that excludes the row updates nothing
and surfaces as Ecto.StaleEntryError, which is the intended outcome and is
rescued rather than propagated.

dismiss_selection/3 discarded the Repo.transaction/1 result and always returned
:ok, so a rolled-back clique reported "Marked as not duplicates" with nothing
written. It now returns {:error, reason} and the LiveView keeps the selection
and flashes an error instead of redirecting.

Every phx-value payload on the screen went straight into String.to_existing_atom/1
or String.to_integer/1. Any existing atom that is not a Contact key -- :page_title,
:flash, an assign name -- reached Map.fetch!/2 and killed the process with a
KeyError; a non-numeric id raised ArgumentError. Payloads are now cast against
the list the render side drew from and an unrecognised one is dropped, matching
how find_cluster/2 already treats an unparseable URL id.
cluster_count/1 backs the sidebar badge, which UserAuth resolves in an
on_mount hook -- so it ran for every authenticated LiveView, twice per
navigation, on screens with nothing to do with duplicates. Each run loaded
every pending *and* dismissed candidate row for the account and re-ran the
union-find pass. dismiss_selection/3 writes a full clique per dismissal and
those rows are never deleted, so that load grew for the life of the account.

Two changes. derive_groups/1 now loads only the dismissed pairs with both
endpoints in the pending set: ensure_group/2 is called with nothing but
pending endpoints, so every group member is one, and blocked?/4 only looks up
ids drawn from two groups -- a negative edge touching a contact no pending pair
mentions can never be consulted. That bounds the derivation by the work
actually pending rather than by the account's whole dismissal history, and
leaves every derived cluster identical. cluster_count/1 is then cached per
account in :kith_cache, with every write path that can change the answer
dropping the entry and the TTL left as a backstop.

MergeFields caches both halves of non_clearable/0, not just the query half: the
changeset half built a %Contact{} and ran update_changeset/2 on every call, and
non_clearable?/1 runs once per choice field per render -- 17 changesets a render
-- and again for every field inside the merge transaction.

query_not_null_fields/0 scopes information_schema.columns to current_schema().
It spans every schema in the database, so any other contacts table contributed
its NOT NULL columns to the union, and a column nullable here but NOT NULL
there would have been reported non-clearable and hidden the "Leave empty"
button.

MergeSummary.history/1 counts relationships through related_contact_id as well
as contact_id. remap_relationships/4 rewrites both, so a member that is only
the target of relationships contributed 0 and the "records move to the primary"
line understated the merge.
maybe_require_tos/1 reads :require_tos_acceptance from Application.get_env/3,
which is node-global. TosAcceptanceTest and RegistrationTest both flipped it
while running async: true, so the setting leaked into every case running at the
same time. Any concurrent test building a registration changeset without
tos_accepted failed on the acceptance validation, and the two modules clobbered
each other's expectations in return.

That is what made settings_multitenancy_test.exs:324 flaky: it passes raw attrs
to accept_invitation/2 rather than going through AccountsFixtures, which always
sets tos_accepted: true. Running those three files together reproduced it in 2
of 5 runs, failing 3 tests each time; 6 of 6 runs are clean now, as are three
full suite runs.

Both modules are async: false, which is already what every other env-mutating
test module here does -- ExUnit runs sync cases serially and never alongside
async ones.
…tory

Ten comments across the cluster merge screen, MergeFields, MergeSummary and
their tests explained a constraint by describing what the code did before it
held -- "used to match on the label", "were offered as clearable", "before
these guards". A reader cannot check that against the code in front of them,
and it dates the comment.

Each now states the same constraint in the present, keeping the reasoning that
made it worth writing: what breaks if the invariant is dropped, not what broke
once. No behaviour change.

The commented-out block in user_auth.ex is left alone -- it is the phx_gen_auth
generator's illustrative example, not dead code, and this branch does not touch
it.
Two comment blocks in the cluster merge template used <!-- --> which HEEx
treats as markup, so they compiled into the response body and shipped the
internal design-spec references to anyone viewing page source. <%!-- --%>
is the compile-time form and stays in the source file.
@bashar-qassis
bashar-qassis marked this pull request as ready for review August 28, 2026 19:23
@bashar-qassis
bashar-qassis merged commit f33d17d into release/0.5.0 Aug 28, 2026
@bashar-qassis
bashar-qassis deleted the merge-revamp-slice-2 branch August 28, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant