Skip to content

Merge revamp slice 1: N-way merge engine, shared resolver, and two data-loss fixes - #27

Merged
bashar-qassis merged 50 commits into
release/0.5.0from
worktree-merge-revamp
Aug 28, 2026
Merged

bashar-qassis merged 50 commits into
release/0.5.0from
worktree-merge-revamp

Conversation

@bashar-qassis

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

Copy link
Copy Markdown
Member

Slice 1 of the duplicate-contact merge revamp. Replaces the two-contact merge engine with an N-way merge_cluster/4 backed by a shared resolution module, and fixes two data-loss bugs.

No user-visible change: the existing merge wizard and POST /api/contacts/merge keep working through a thin merge_contacts/3 shim over the new engine. The only behaviour difference is that merging no longer dismisses duplicate suggestions the user never saw.

Spec: docs/superpowers/specs/2026-08-17-duplicate-merge-revamp-design.md
Plan: docs/superpowers/plans/2026-08-17-merge-revamp-slice-1-engine.md

What's here

  • Kith.Contacts.MergeFields — declares which contact fields are mergeable and how. non_clearable/0 derives from the changeset's validate_required list rather than being hand-maintained.
  • Kith.Contacts.MergeResolution — a pure resolver: members in, complete field map + conflicts + attributions out. No DB access. One implementation shared by every caller.
  • Kith.Contacts.Merge — the engine. One transaction: lock, validate, apply scalars, remap, deduplicate, apply the drop list, resolve candidate pairs, soft-delete the losers, audit.
  • Bug 1 fixed — merging no longer orphans debts, gifts, pets, tasks, conversations, Immich candidates, reminder instances or inbound first_met_through references on the trashed contact.
  • Bug 2 fixed — the blanket dismiss_candidates_for_contact/2 is gone. Pair resolution now only dismisses pairs the user actually reviewed.

Spec scenarios covered

D1–D10, F4, G1, G3, Bug 1, Bug 2. D9 (concurrent merges) has its locking implemented but no automated test — FOR UPDATE contention needs two connections and the Ecto sandbox gives one, so it needs a manual two-iex check before this ships.

Notable decisions made during implementation

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

  • The lock now sits inside the transaction. As planned, SELECT ... FOR UPDATE ran before Repo.transaction, so the lock was released immediately and D9 would not have held. order_by: c.id was added for deterministic lock acquisition, since two overlapping merges could otherwise deadlock.
  • Collision guards cover the whole cluster. The first implementation compared each loser only against the survivor. Two losers sharing a tag, activity, photo hash or Immich photo id — with the survivor holding neither — would violate a unique index and raise. immich_candidates had no guard at all, and reminders has a birthday partial unique index that the commonest merge of all would have hit.
  • Dropped loser-owned records ride to trash with their owner instead of being hard-deleted; only survivor-owned drops are deleted outright, per spec §2.
  • DisplayNameRecomputeWorker is not enqueued. That worker is account-wide and cannot act on one contact, so the job could never succeed. Contact.update_changeset/2 already recomputes display_name synchronously. The spec's §2 step 10 and scenario D10 are annotated with this.
  • D4 is enforced in the engine, not just in the resolver — a first_met_through_id naming any merged member is coerced to nil, so no caller can produce a contact met through itself.
  • A merge with every member at Immich needs_review used to fail outright with {:unknown_value, :immich_status} — the normal post-sync state for a duplicate pair, so those pairs were unmergeable.
  • Addresses are deduplicated (spec §3 named them, no task implemented it), and the contact-field dedupe key is normalized rather than exact.

Verification

  • 1257 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; several were verified by reverting the fix and confirming the failure.

Before merging

  • Manual D9 check: two iex sessions merging overlapping clusters — the second must block, then fail validation on the now-trashed member rather than merging.
  • Unrelated pre-existing flake worth a separate fix: test/kith/accounts/tos_acceptance_test.exs runs async: true while mutating global app env (:require_tos_acceptance), which User.maybe_require_tos/1 reads. Any concurrent test creating a user then fails with "you must accept the Terms of Service". One-word fix (async: false); untouched here as out of scope.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DCMNiVE7SKJeMusAn7DiMd

@bashar-qassis bashar-qassis changed the title worktree merge revamp Merge revamp slice 1: N-way merge engine, shared resolver, and two data-loss fixes Aug 18, 2026
@bashar-qassis
bashar-qassis changed the base branch from main to release/0.5.0 August 19, 2026 21:36
@bashar-qassis
bashar-qassis force-pushed the worktree-merge-revamp branch 2 times, most recently from 9c82882 to a70bda4 Compare August 19, 2026 22:00
… just vs. survivor

Handles loser-vs-loser collisions on contact_tags, activity_contacts and
photos (unique indexes), adds the same treatment for immich_candidates,
and resolves colliding birthday reminders (partial unique index on
reminders) by keeping the survivor's own reminder or the lowest-id
loser's. Extracts the collision handling into a shared dedupe_and_move/5
helper.
Addresses were never deduplicated after merge (plan gap across every task);
add the design spec's line1+postal_code normalized dedupe. Normalize the
contact_fields dedupe key too (case/whitespace-insensitive per spec §3).
Add loser-vs-survivor relationship collision regression tests.
Two addresses lacking both line1 and postal_code normalize to the same
empty key but can be different places (differ by city/region/country/
label). Only dedupe when the key has actual content.
…n, document unchecked_ids, add loser-collision and account-isolation tests
@bashar-qassis
bashar-qassis force-pushed the worktree-merge-revamp branch from a70bda4 to f1a1602 Compare August 19, 2026 22:13
The wizard seeded field_choices with "survivor" for every mergeable field,
which apply_legacy_choices/4 reads as a deliberate pin and turns into :clear
whenever the survivor's value is nil. Every wizard merge therefore discarded
the resolver's gap fill. Seed nothing; compute the radio's selected state.
…dios

The old assertion checked whether "Acme" appeared anywhere in the rendered
HTML, but both columns render their value unconditionally, so it passed even
when the highlight sat on the wrong button. Assert on the accent-subtle class
per button instead, and add the converse case (survivor value kept) so the
assertion is shown to discriminate in both directions.
Only birthday reminders were deduped, but no unique index covers
stay_in_touch, so a merge could leave two active rows on the survivor and
Reminders.resolve_stay_in_touch_instance/1 would raise MultipleResultsError
on the next logged interaction. Dedupe in the engine and make the lookup
total with limit: 1.
Merging a user's me-contact away left the FK naming a trashed row; the purge
worker then hard-deleted it and on_delete: :nilify_all silently cleared the
link 30 days after the merge that caused it.
merge_cluster/4 is documented to return {:error, reason}; an unrecognised
field key raised inside the transaction and would kill the caller's
LiveView process rather than surfacing a flash.
merge_cluster/4 is the caller-facing function; its own @doc had a separate
error-reason list from run/4's and was left out of the prior commit.
Also missing from run/4's list, and genuinely returnable via
validate_drop/2. Folded into this doc-fix series per review ruling
rather than deferred as a separate change.
resync_birthday_reminder/4 refilled enqueued_oban_job_ids while active
stayed false, undoing what deactivation deliberately cleared. Keep tracking
next_reminder_date; skip the enqueue.
The round-1 gap-fill test only called merge_contacts/2, the arity the UI never
uses, which is why the wizard's default-choices bug survived review. Pair each
resolver assertion with a 3-arity run, and add structural tests asserting the
engine's registries still cover every contact_id column and every FK into
contacts.
…omments

Review found two disclosure problems in the Task 6 audit:

- merge_invariants_test.exs's two structural tests cannot see a pointer that
  is both unnamed contact_id and has no FK (import_records.local_entity_id's
  shape exactly). Document the blind spot in the moduledoc and pin the one
  known instance with a third, structural test that fails if
  remap_import_records_step/4 is removed or unwired from build_multi/7 — a
  behavioural test for it already exists, so this doesn't duplicate a merge.

- The Part 2 wizard-path test comments implied they closed the same
  arity-based gap that let Finding 1 survive review. They don't:
  apply_legacy_choices/4 resolves each field independently, so that gap
  cannot occur there. Reworded the comments to state the narrower invariant
  they actually verify (an unrelated explicit choice must not disturb an
  untouched field's resolution) without overclaiming.
…opaque

Every Ecto.Multi pipeline trips call_without_opaque. The cause is upstream:
Ecto.Multi's `names` field is typed MapSet.t(), MapSet.t/0 is opaque, and
Dialyzer constant-folds Multi.new/0 to a struct literal holding a
structurally built MapSet where an opaque one is expected. Ecto.Multi's own
t/0 is not opaque, so this is not internal-field access on our side.

No code shape fixes it — routing Multi.new/0 through a `@spec _ ::
Multi.t()` boundary only relocates the warning to contract_with_opaque,
because Multi.new/0's success typing cannot satisfy Multi.t() itself.

Replaces 22 :nowarn_function escapes across six contexts, each of which was
muting every check on its function rather than just the opacity warning.
All 22 were verified to be this false positive alone: narrowing them to
:no_opaque surfaced no new warnings before they were removed entirely.

lint_rtl.ex keeps its :nowarn_function — that one is an :error_handling
warning on a Mix task, unrelated to Ecto.Multi.
Comments describing superseded versions of the code rather than the code as
it stands: the @owned_schemas note counted schemas "the previous two-contact
engine silently orphaned" (a count that rots the moment the list is
reordered, now stating the exhaustiveness requirement and its invariant test
instead), :remap_contact_tags claimed to mirror "the old engine's" step, and
resync_birthday_reminder/4 carried a bare note that a function had been
renamed in an earlier task.
Rewrite comments that described the edit rather than the code: the
import_records note left behind by the removed fixup helper, the wizard's
default_field_choices/0 note, and the :no_opaque rationale in mix.exs.

Replace the `Task 1`/`Task 3` plan-doc pointers in the merge tests with the
behaviour they refer to, and drop the "as originally assumed" narration from
the invariant test's known-column list.

Rewrap an over-long line in @owned_schemas' comment and remove its duplicate
of the MergeInvariantsTest claim already stated at __owned_schemas__.
@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 worktree-merge-revamp 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