Skip to content

Admin — Person Merge

The admin's tool for merging two Person rows when a duplicate is discovered (e.g. two stringers each created a draft Person for the same human, or a verified-email match was missed). Owned by Mira. Cross-cuts: ADR-0004 § Person merge, data-model § person_merges, client-identity-and-sharing § identity matching, stringer-dashboard, design-tokens.

Source requirement

  • ADR-0004 § Person merge — admin-merge data shape: person_merges(id, surviving_person_id, merged_person_id, merged_at, merged_by_admin_id, reason). Merge is admin-only; on-write fan-out (Implementation A): UPDATE every client_profiles.person_id, every order_shares.granter_person_id (where granter_kind=person), every person_stringer_share.person_id from merged_person_id to surviving_person_id, then mark persons.merged_into = surviving_person_id.
  • ADR-0004 § Costs we accept — "The Person merge tool is a real future obligation. Designed now, built later — but the obligation is real and we should not be surprised when the first duplicate appears."
  • client-identity-and-sharing § identity matching when adding a client — verified-email auto-match is automatic and does NOT need this UI. This UI is the explicit-action surface for the cases verified-email-match misses.
  • Round-3 scope — every merge writes to share_audit with event_kind = person_merge (a new event_kind extending the existing enum; Theo flag).
  • fadp-posture § A-CONS-1 — Person creation provenance (Person.created_by_kind, Person.created_by_id) is a related schema ask; the merge tool surfaces this provenance in the side-by-side comparison so the admin sees who created each duplicate.

Goal

Three commitments:

  1. Merge is rare and consequential — friction is welcome. Unlike the catalogue queue (Stefan triages dozens), Person merge is a "once in three months" surface. The UI optimises for decision quality over speed: side-by-side comparison; explicit choice of surviving row; typed-confirm on the destructive step.
  2. The audit trail is load-bearing. Every merge writes a person_merges row AND a share_audit row. The UI surfaces the audit history of merges per Person so a future debugger can answer "did Stefan ever merge this person?".
  3. Verified-email matches are out of scope. Per client-identity-and-sharing, verified-email collisions auto-match at create-time. This tool is for the fuzzy-name-match case where the platform did not detect the duplicate at creation.

Information architecture

Admin-only route at /admin/persons/merge. Three stages:

Stage 1 — Search                   Stage 2 — Compare              Stage 3 — Merge
─────────────────►─────────────────────────────────────►───────────────────►─────►
[search by name]  pick two  →  [side-by-side detail panes]  →  [typed-confirm modal]
                                  ├ Person A summary
                                  ├ Person B summary       Pick surviving row;     Merge fan-out runs;
                                  ├ ClientProfiles per     write reason; type      success state;
                                  ├ Recent orders per      surviving person's      Person merges row +
                                  └ Provenance per         display-name to confirm share_audit row.

Each stage is a distinct route; back-arrow navigation preserves search / selection state via query string.

/admin/persons/merge

sm 375 px

┌───────────────────────────────────────┐
│ ←  Person merge                       │  Header
├───────────────────────────────────────┤
│                                       │
│  Find duplicates                      │  H2
│                                       │
│  Use this when two Person rows are    │  text-body slate-600
│  the same human. Verified-email       │
│  matches are automatic — this tool    │
│  is for fuzzy-name matches.           │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search by name                 │ │  Input
│ └───────────────────────────────────┘ │
│  Try: last name, first name, or       │  text-tiny slate-500
│  partial spelling.                    │
│                                       │
│  Suggested duplicates                 │  H3 — auto-detected pairs
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller (verified email)     │ │  Person A
│ │     vs                            │ │
│ │ Lukas Mueller (no email)          │ │  Person B — likely dupe
│ │ 92% name similarity               │ │  text-tiny slate-500
│ │                       Compare →   │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ Anna Bauer (verified email)       │ │
│ │     vs                            │ │
│ │ Anna B. (no email)                │ │
│ │ 78% name similarity               │ │
│ │                       Compare →   │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Or, find a person                    │  Group label
│ ┌───────────────────────────────────┐ │
│ │ • Lukas Müller (verified)         │ │  Search-result row
│ │   2 ClientProfiles · 17 orders    │ │
│ │                       Select →    │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│  Recent merges                        │  H3 — audit trail
│ ┌───────────────────────────────────┐ │
│ │ 2026-04-12 — admin Stefan Wagen   │ │
│ │ Merged "John Smith" into          │ │
│ │ "John Smith (verified)"           │ │
│ │ Reason: same human, two profiles  │ │
│ └───────────────────────────────────┘ │
│  See all merges →                     │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Suggested duplicates auto-runs server-side at page load. Per ADR-0004 § Person merge "the obligation is real": we proactively surface likely matches so Stefan does not have to remember to check. Algorithm (server-side, simple): pairs of Person rows where levenshtein(display_first_name || ' ' || display_last_name, …) < 4 AND merged_into IS NULL AND not already in a recent person_merges row. Top 5 suggestions, sorted by descending similarity.
  • Search input. keyup HTMX swap of the result list (hx-trigger="keyup changed delay:200ms"). Searches display_first_name, display_last_name, email (admin sees emails). Returns up to 20 rows.
  • Search-result row. Tap "Select →" picks this Person as Person A; the page transitions to a "Pick the duplicate" sub-state where the admin picks Person B (server re-runs the suggestions, this time against Person A only).
  • Suggested-duplicates row. Tap "Compare →" jumps directly to Stage 2 with both Persons pre-selected.
  • Recent merges. Last 5 person_merges rows, descending. "See all merges →" links to a paginated archive (out of design-spec scope; basic list).

Component breakdown

Component Notes
Page introduction "Use this when two Person rows are the same human..." — text-body slate-600, two sentences. Sets expectations on what this tool is and is NOT.
Search input text-body; lucide:search 16 px leading icon. Min height 44 px. Placeholder "Search by name".
Suggested-duplicate card bg-amber-50 border border-amber-200 rounded-lg p-4. Two-row identity ("Lukas Müller (verified email)" / "vs" / "Lukas Mueller (no email)") with a similarity score below. Right-aligned "Compare →" link/button.
"vs" divider text-tiny slate-500 italic.
Identity row "{display_name} ({email-status})" — verified email shown with lucide:check-circle text-green-700; missing email shown as "(no email)" text-slate-500.
Search-result row bg-white border border-slate-200 rounded-lg p-3. Bullet + name + counts ("2 ClientProfiles · 17 orders").
Recent-merges row bg-slate-50 border border-slate-200 rounded-lg p-3 text-small. Date + admin + before-after summary + reason.

Stage 2 — Side-by-side compare

/admin/persons/merge/compare?a=:person_a_id&b=:person_b_id

sm 375 px (mobile-first — stacked)

┌───────────────────────────────────────┐
│ ←  Compare                            │  Header
├───────────────────────────────────────┤
│                                       │
│  Person A                             │  H2
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │  Display name
│ │ ✓ Verified email                  │ │  text-small green-700
│ │   lukas.mueller@example.com       │ │  text-small slate-700
│ │ Locale: de                        │ │  text-small slate-700
│ │ Created 2024-08-12                │ │
│ │ by stringer Stefan Wagen          │ │  Provenance per fadp-posture
│ │                                   │ │
│ │ ─── ClientProfiles (2) ───        │ │
│ │  • by Stefan Wagen — "Lukas"      │ │
│ │    nickname: "Lu"                 │ │
│ │    17 orders                      │ │
│ │  • by Sarah Reber — "Lukas M."    │ │
│ │    no nickname                    │ │
│ │    3 orders                       │ │
│ │                                   │ │
│ │ ─── Recent orders ───             │ │
│ │  #2026-0042 · 2026-04-30          │ │
│ │  Pure Aero 98 · 24/24 kg          │ │
│ │  via Stefan Wagen                 │ │
│ │  #2026-0038 · 2026-04-15          │ │
│ │  Pure Aero 98 · 25/24 kg          │ │
│ │  via Stefan Wagen                 │ │
│ │  See all 20 orders →              │ │
│ │                                   │ │
│ │ ─── Sharing ───                   │ │
│ │  3 active grants about this       │ │
│ │  Person                           │ │
│ │                                   │ │
│ │ ─── Audit ───                     │ │
│ │  No prior merges                  │ │
│ │                                   │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │  Pick A as surviving          │ │ │  Per-side CTA
│ │ └───────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Person B                             │  H2
│ ┌───────────────────────────────────┐ │
│ │ Lukas Mueller                     │ │
│ │ — No email                        │ │  text-small slate-500
│ │ Locale: de (default)              │ │
│ │ Created 2026-03-22                │ │
│ │ by stringer Marc Egli             │ │
│ │                                   │ │
│ │ ─── ClientProfiles (1) ───        │ │
│ │  • by Marc Egli — "Lukas Mueller" │ │
│ │    no nickname                    │ │
│ │    4 orders                       │ │
│ │                                   │ │
│ │ ─── Recent orders ───             │ │
│ │  #2026-0099 · 2026-04-22          │ │
│ │  Pure Aero 98 · 25/25 kg          │ │
│ │  via Marc Egli                    │ │
│ │  ⋯                                │ │
│ │  See all 4 orders →               │ │
│ │                                   │ │
│ │ ─── Sharing ───                   │ │
│ │  No active grants                 │ │
│ │                                   │ │
│ │ ─── Audit ───                     │ │
│ │  No prior merges                  │ │
│ │                                   │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │  Pick B as surviving          │ │ │
│ │ └───────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── These are different people ───   │  Escape-hatch
│   ┌─────────────────────────────────┐ │
│   │  Cancel — they're different     │ │
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

md 768 px+ (side-by-side)

The two Person panes render in a two-column grid; the Cancel CTA spans both columns at the bottom. Same content; different geometry.

┌─────────────────────────────────────────────────────────────────┐
│ ←  Compare                                                      │
├──────────────────────────────────┬──────────────────────────────┤
│  Person A                        │  Person B                    │
│  Lukas Müller                    │  Lukas Mueller               │
│  ✓ Verified                      │  — No email                  │
│  …summary as sm…                 │  …summary as sm…             │
│  …ClientProfiles…                │  …ClientProfiles…            │
│  …recent orders…                 │  …recent orders…             │
│  …sharing…                       │  …sharing…                   │
│  …audit…                         │  …audit…                     │
│                                  │                              │
│  [ Pick A as surviving ]         │  [ Pick B as surviving ]     │
└──────────────────────────────────┴──────────────────────────────┘
                  [ Cancel — they're different ]

Behaviour

  • The "surviving Person" is the row whose ID survives. The other Person's merged_into FK is set, and all references (ClientProfiles, share grants) are migrated to the surviving Person via the on-write fan-out per ADR-0004 § Person merge implementation A.
  • Decision aid. The pane summaries highlight the asymmetries: verified email > no email; older created_at > newer; more ClientProfiles > fewer; more orders > fewer. Stefan looks at the panes and decides which is the canonical record.
  • Strong default visual hint: the pane with the verified email gets a subtle bg-indigo-50 tint (vs. the bare bg-white of the other) — surfaces "this is the more-authoritative-looking row" without preselecting.
  • "Pick A / B as surviving" buttons advance to Stage 3 with surviving=:id and merging=:other_id query params.
  • Cancel — they're different. Returns to Stage 1 search; the candidate pair is logged as "considered but not merged" via a single share_audit row with event_kind = person_merge_considered_rejected for future audit transparency. (See Open question #2 on whether to log this rejection too.)

Sub-block: ClientProfiles

For each Person, list every ClientProfile referencing it: created_by_stringer_id resolved to display name, the profile's nickname (if set), and the count of orders under that profile. This surface tells the admin "after the merge, the surviving Person will have 3 ClientProfiles" so they understand the post-merge shape.

Sub-block: Recent orders

Last 3 orders per Person, with receipt number, date, racket model, tension, and via-stringer. "See all N orders →" links to a Person-scoped order list (admin view; out of merge-spec scope but trivial).

Sub-block: Sharing

Active order_shares and person_stringer_share count per Person. (Per ADR-0004 § Person merge on-write fan-out — these grants are migrated to the surviving Person at merge time. Surfacing the count tells the admin the migration shape.)

Sub-block: Audit

Prior person_merges rows where this Person is the surviving_person_id or merged_person_id. "No prior merges" is the common case. If non-empty, the surface shows a list with "Merged from {other_name} on {date} by {admin}" — Stefan needs to know the merge history before chaining another merge into a row that already absorbed a different identity.

Component breakdown

Component Notes
Person pane bg-white border border-slate-200 rounded-lg p-4. With verified email: border-indigo-300 bg-indigo-50/30.
Display name text-h3.
Verified-email line lucide:check-circle text-green-700 + "Verified email" + email below.
No-email line "— No email" text-slate-500 italic.
Locale line "Locale: {de
Provenance line "Created {date} by stringer {name}" — text-small slate-700. Per fadp-posture A-CONS-1.
Sub-block heading text-small slate-500 uppercase tracking-wide; horizontal rule per design-tokens.
ClientProfile row text-small; bullet + "by {stringer} — '{label}'" + nickname + order count.
Recent-order row Receipt number + date + spec summary; text-small slate-700.
Sharing line "{N} active grants about this Person" or "No active grants" — text-small slate-700.
Audit line "No prior merges" text-small slate-500 italic — common case. Or list.
"Pick A/B as surviving" CTA Full-width per pane; bg-indigo-700 text-white.
Cancel CTA Full-width below both panes (or below the stacked pair on sm); bg-white border border-slate-300 text-slate-700.

Stage 3 — Merge confirmation

/admin/persons/merge/confirm?surviving=:id&merging=:other_id

sm 375 px

┌───────────────────────────────────────┐
│ ←  Confirm merge                      │  Header
├───────────────────────────────────────┤
│                                       │
│  ⚠ This will permanently merge        │  H1 + lucide:alert-triangle
│    two Person rows.                   │  amber-700
│                                       │
│  Surviving                            │  H3
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │
│ │ ✓ lukas.mueller@example.com       │ │
│ │ Created 2024-08-12 by Stefan      │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Merging into the above (tombstoned)  │  H3
│ ┌───────────────────────────────────┐ │
│ │ Lukas Mueller                     │ │
│ │ — No email                        │ │
│ │ Created 2026-03-22 by Marc        │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── What happens ───                 │  Group label
│                                       │
│  ✓ 1 ClientProfile under Marc Egli    │  text-small slate-700
│    is reassigned to the surviving     │
│    Person.                            │
│  ✓ 4 orders are reassigned via        │
│    that ClientProfile.                │
│  ✓ 0 share grants migrate.            │
│  ✓ The merging Person row stays as    │
│    a tombstone with merged_into       │
│    pointing to the surviving Person.  │
│  ✓ One audit row is written.          │
│                                       │
│  ─── Reason ───                       │
│  Required.                            │  text-small slate-700
│ ┌───────────────────────────────────┐ │
│ │                                   │ │  Textarea
│ │                                   │ │
│ └───────────────────────────────────┘ │
│  Stored on the merge for the audit    │  text-tiny slate-500
│  trail.                               │
│                                       │
│  ─── Confirm ───                      │
│  To confirm, type the surviving       │  text-small slate-700
│  Person's display name:               │
│  "Lukas Müller"                       │  text-body bold slate-900
│                                       │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │  Confirmation input
│ └───────────────────────────────────┘ │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │           Cancel                │ │
│   └─────────────────────────────────┘ │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │  Merge into Lukas Müller        │ │  Primary CTA — destructive
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • The page is the modal. Stage 3 is its own route — not a modal — because the typed-confirm + reason + dry-run preview deserve real estate. (Compare to the catalogue-moderation reject modal, which is small.)
  • "What happens" preview. Server-rendered count of every entity affected by the on-write fan-out:
  • N ClientProfiles reassigned (count from client_profiles WHERE person_id = :merging_id).
  • N orders reassigned via those ClientProfiles (count from the JOIN — informational; the on-write fan-out updates client_profiles.person_id, not orders directly, but Stefan thinks in orders).
  • N share grants migrate (count from order_shares WHERE granter_kind = 'person' AND granter_person_id = :merging_id PLUS person_stringer_share WHERE person_id = :merging_id).
  • The tombstone clarification.
  • One audit row.
  • Reason field. Required, textarea, ≤ 500 chars. Stored in person_merges.reason. Validation: non-empty.
  • Typed-confirm. The admin must type the surviving Person's display name exactly (including any special characters — "Lukas Müller" with the umlaut). The Merge CTA is disabled until the typed text matches. This is the destructive-action gate per the stringer-lifecycle finalize convention.
  • Merge CTA is bg-red-700 text-white (destructive). Label includes the surviving name: "Merge into Lukas Müller".
  • On submit: server runs the merge transaction:
  • INSERT one row in person_merges.
  • UPDATE every client_profiles.person_id from merged_person_id to surviving_person_id.
  • UPDATE every order_shares.granter_person_id (where granter_kind = 'person' AND granter_person_id = merged_person_id) to surviving_person_id.
  • UPDATE every person_stringer_share.person_id from merged_person_id to surviving_person_id.
  • UPDATE persons.merged_into = surviving_person_id on the merging row; set email = NULL on the merging row to release the email constraint (per ADR-0004 §FADP positioning — clearing email prevents silent re-conflation).
  • INSERT one row in share_audit with event_kind = person_merge, actor_kind = stringer (admin is a stringer with role = admin), actor_id = :admin_id, target_kind = person_merges, target_id = :merge_id, meta = {"surviving_person_id": ..., "merged_person_id": ..., "reason": ...}.
  • Success state: redirect to /admin/persons/merge?merged=:merge_id (Stage 1) with a toast: "Merged Lukas Mueller into Lukas Müller. 4 orders reassigned." The recent-merges block on Stage 1 reflects the new row immediately.
  • Failure handling. If the merge transaction fails (e.g. concurrent write conflict — unlikely at V2 scale but defensive), toast: "Merge couldn't complete — try again." Stay on Stage 3 with all fields preserved.

Component breakdown

Component Notes
Header "Confirm merge" text-h2. Back arrow returns to Stage 2 with both Person IDs preserved.
Warning H1 text-h1 with lucide:alert-triangle text-amber-700 24 px inline.
Surviving / Merging panes Compact summary version of Stage 2 panes; show display name, email status, creation provenance.
What-happens list <ul> with lucide:check 16 px; text-small slate-700.
Reason textarea text-body; min-h-20; required.
Typed-confirm prompt "To confirm, type the surviving Person's display name:" + the literal name in text-body bold slate-900.
Typed-confirm input text-body; placeholder empty; aria-describedby linked to the prompt above.
Cancel bg-white border border-slate-300 text-slate-700; full-width.
Merge CTA (destructive) bg-red-700 text-white; full-width; disabled until typed-confirm matches. Label includes surviving name.

Audit-log surfacing

Per the round-3 scope: every merge writes a share_audit row with event_kind = person_merge. This event_kind extends the existing enum per ADR-0004 amendment (Theo flag — already cross-referenced in fadp-posture § Schema asks 7).

The merge audit surfaces in three places:

  1. Stage 1 — Recent merges block (top 5 + link to archive).
  2. Stage 2 — Audit sub-block per Person pane (any prior merges involving this Person).
  3. Per-Person admin detail view (out of merge-spec scope; flagged for future surface): a Person admin-detail page would render its full audit timeline including merges.

The share_audit row enables a future generic "audit log" admin search across all event kinds; this surface only writes the row, doesn't render the cross-event search (that's a separate admin tooling round).

Interaction states

State What renders
Stage 1 — initial Search input + suggested duplicates + recent merges.
Stage 1 — search typed HTMX swap of result list per keyup (200 ms debounce).
Stage 1 — no suggestions, no search "No suggested duplicates right now. Search for a person above."
Stage 1 — empty results "No matches. Try a different spelling."
Stage 2 — both Persons selected Side-by-side panes; surviving-leaning hint via subtle bg tint.
Stage 2 — same Person ID for A and B (URL hack) Server-side guard; redirect to Stage 1 with toast: "Pick two different Persons."
Stage 2 — already-merged Person (one of A/B has merged_into set) Render the pane with a bg-amber-50 border border-amber-200 "Already merged" overlay card explaining: "This Person was merged into {surviving_name} on {date}. To merge a different pair, search again." Cancel CTA visible.
Stage 3 — initial Reason empty; typed-confirm empty; Merge CTA disabled.
Stage 3 — reason filled, typed-confirm not matched Merge CTA disabled.
Stage 3 — reason filled, typed-confirm matches exactly Merge CTA enabled (bg-red-700).
Stage 3 — typed-confirm with wrong casing or spacing Merge CTA disabled. Inline hint below the typed-confirm input: "Match the display name exactly, including spelling and special characters."
Stage 3 — submit pending Merge CTA shows "Merging…" with 16 px spinner; disabled.
Stage 3 — success Redirect to Stage 1 with confirmation toast.
Stage 3 — failure Toast "Merge couldn't complete — try again." Form preserved.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Stage 2 — Person A == Person B "Pick two different Persons." (Server-side guard; UI prevents this in the pickers.)
Stage 2 — Person already merged Inline overlay; navigation path explained.
Stage 3 — reason empty "Please write a reason for the audit log."
Stage 3 — reason > 500 chars "Reason is too long (max 500)."
Stage 3 — typed-confirm doesn't match "Match the display name exactly, including spelling and special characters."
Stage 3 — concurrent merge race "This Person was merged by another action — search again." (V2 reality: no other admin; defensive.)

Accessibility

  • Stage 1 search input uses <label> "Search by name" (visually hidden, screen-reader visible) + <input type="search"> for the dynamic suggestions semantic.
  • Suggested-duplicate cards are <button> semantic at the card level (the whole card is the trigger); the "Compare →" affordance is a visible label, not the only target. Hit area = full card.
  • Stage 2 panes are <section aria-labelledby="person-a-heading"> / <section aria-labelledby="person-b-heading">; sub-blocks use <h3> for ClientProfiles / Recent orders / Sharing / Audit.
  • Verified-email signal uses both icon AND text — "✓ Verified email" — never icon-only.
  • Stage 3 destructive CTA uses aria-describedby linking to the "what happens" list, so a screen-reader user gets the dry-run summary in context.
  • Typed-confirm input uses aria-describedby linking to the prompt; the disabled state of the Merge CTA is announced via aria-disabled="true".
  • Hit targets: all buttons ≥ 44 × 44 px.
  • Color contrast: the destructive bg-red-700 text-white = 5.94:1 (AA). The amber-warning H1 uses text-amber-700 on white (4.5:1 — AA for body). The verified-email green-700 on white (5.7:1 — AA).
  • Focus management: advancing through Stage 1 → 2 → 3 moves focus to the H1 / H2 of the new stage. Returning via the back arrow restores focus to the previously-actioned element.
  • Stage 3 typed-confirm is the keyboard-only path to enable the destructive CTA — no mouse-only shortcut. (No "double-click to confirm" or similar; typed-confirm is keyboard-friendly.)

HTMX / progressive-enhancement seams

  • Stage 1 search: hx-get="/admin/persons/merge/_search?q=…" hx-trigger="keyup changed delay:200ms" hx-target="#search-results". Without JS: regular GET with ?q=… query param; full page reload re-renders the results.
  • Stage 1 suggested-duplicates: server-rendered on initial page load; no HTMX needed.
  • Stage 2 enter: regular GET /admin/persons/merge/compare?a=…&b=…. No HTMX trickery — the page is a full render with two server-side queries.
  • Stage 2 "Pick A as surviving": regular <a href="/admin/persons/merge/confirm?surviving=A&merging=B"> link.
  • Stage 3 typed-confirm validation: the Merge CTA's enabled/disabled state is computed server-side on submit. The UI provides an HTMX-driven client-side enable/disable for UX comfort (hx-trigger="keyup" hx-get="/admin/persons/merge/_check_match" hx-target="#merge-cta"), but the canonical check is server-side on the POST: if the typed-confirm string doesn't match the surviving Person's display name at submit time, the server rejects with 422 and re-renders the form. Without JS: the user types, clicks Merge, and gets the validation error if mismatched.
  • Stage 3 submit: regular <form method="post" action="/admin/persons/merge/execute">. The whole merge transaction is one request; HTMX swap is optional (return the success-redirect URL via hx-redirect header).

The whole flow's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.

i18n affordance

String Type Catalogue key
"Person merge" (page title) {% trans %} admin.merge.title
"Find duplicates" / page intro {% trans %} admin.merge.search.{title,intro}
"Search by name" {% trans %} admin.merge.search.placeholder
"Try: last name, first name, or partial spelling." {% trans %} admin.merge.search.hint
"Suggested duplicates" / "Or, find a person" / "Recent merges" {% trans %} admin.merge.search.section.{suggested,find,recent}
"{N}% name similarity" {% trans %} (Babel) admin.merge.search.similarity
"Verified email" / "No email" / "Locale: {locale}" / "(default)" {% trans %} (Babel) admin.merge.identity.{verified,no_email,locale,locale_default}
"Created {date} by stringer {name}" {% trans %} (Babel) admin.merge.identity.provenance
"Compare →" / "Select →" / "See all merges →" {% trans %} admin.merge.action.{compare,select,see_merges}
"Merged \"{from}\" into \"{into}\"" / "Reason: {reason}" {% trans %} (Babel) admin.merge.recent.summary / admin.merge.recent.reason
"Compare" (Stage 2 header) {% trans %} admin.merge.compare.title
"Person A" / "Person B" {% trans %} admin.merge.compare.person_{a,b}
"ClientProfiles ({N})" / "Recent orders" / "Sharing" / "Audit" {% trans %} (Babel for count) admin.merge.compare.section.{profiles,orders,sharing,audit}
"by {stringer} — '{label}'" / "nickname: '{nickname}'" / "no nickname" {% trans %} (Babel) admin.merge.profile.{by,nickname,no_nickname}
"{N} orders" / "via {stringer}" / "See all {N} orders →" {% trans %} (Babel) admin.merge.orders.{count,via,see_all}
"{N} active grants about this Person" / "No active grants" {% trans %} (Babel) admin.merge.sharing.{active,none}
"No prior merges" / "Merged from {other_name} on {date} by {admin}" {% trans %} (Babel) admin.merge.audit.{none,prior}
"Pick A as surviving" / "Pick B as surviving" / "Cancel — they're different" {% trans %} admin.merge.compare.cta.{pick_a,pick_b,cancel}
"Confirm merge" (Stage 3 header) {% trans %} admin.merge.confirm.title
"This will permanently merge two Person rows." {% trans %} admin.merge.confirm.warning
"Surviving" / "Merging into the above (tombstoned)" {% trans %} admin.merge.confirm.{surviving,merging}
"What happens" + the dry-run bullets {% trans %} (Babel for counts) admin.merge.confirm.what_happens.{title,profile_reassign,order_reassign,grant_migrate,tombstone,audit}
"Reason" / "Required." / "Stored on the merge for the audit trail." {% trans %} admin.merge.confirm.reason.{label,required,hint}
"Confirm" / "To confirm, type the surviving Person's display name:" {% trans %} admin.merge.confirm.typed.{title,prompt}
"Match the display name exactly, including spelling and special characters." {% trans %} admin.merge.confirm.typed.mismatch
"Cancel" / "Merge into {name}" {% trans %} (Babel) admin.merge.confirm.cta.{cancel,merge}
Toast — "Merged {merged_name} into {surviving_name}. {N} orders reassigned." {% trans %} (Babel) admin.merge.toast.success
Toast — "Merge couldn't complete — try again." {% trans %} admin.merge.toast.failure
"This Person was merged into {surviving_name} on {date}. To merge a different pair, search again." {% trans %} (Babel) admin.merge.compare.already_merged
Person display names, emails, dates Data n/a

Iris's DE pass follows after merge.

DE width budget (designer note)

  • "Person merge" → "Personen zusammenführen" (~1.7×) — fits in header.
  • "Find duplicates" → "Duplikate finden" (similar).
  • "Pick A as surviving" → "A als überlebende Person wählen" (~1.7×) — wraps inside the pane width (max ~480 px on md).
  • "Merge into {name}" → "Zusammenführen in {name}" (~1.4×) — fits CTA.
  • "Match the display name exactly..." → "Tippe den Anzeigenamen genau ab, einschließlich Schreibweise und Sonderzeichen." (~1.2×) — wraps.
  • The two-column compare layout on md may need pane width extended slightly in DE; reserve min-w-[360px] per pane.

Cross-references

  • Source ADR: ADR-0004 § Person merge — schema, on-write fan-out, "designed now, built later".
  • Schema: data-model § person_merges (table shape); fadp-posture § Schema asks (related: Person.created_by_kind / Person.created_by_id for provenance, share_audit.event_kind extension to include person_merge).
  • Identity model: client-identity-and-sharing — verified-email auto-match (which this UI does NOT cover); fuzzy-name-match (which this UI does cover).
  • FADP context: fadp-posture — Person erasure is a separate flow (see admin-dsar-queue); merge is a different operation.
  • Linked design surfaces: stringer-dashboard, admin-dsar-queue (sibling admin tooling), design-tokens.
  • Future surfaces:
  • Per-Person admin detail page — full audit timeline for one Person across all event kinds. Queued; not in Round 3.
  • Generic admin audit-log search — across share_audit for any event kind. Queued; not in Round 3.
  • Bulk merge — merging a chain of three+ Persons in one transaction (V3+; rare).
  • Coordination: Theo (schema amendment for share_audit.event_kind = person_merge); Pax-B (on-write fan-out implementation); Iris (DE pass + verifying the consent / FADP positioning); Juno (frontend implementation).
  • i18n strategy: i18n architecture.
  • Issue tracking: racket-book#109.

Open questions for Stefan (with proposed defaults)

  1. Suggested-duplicates algorithm threshold. Proposed default: levenshtein ≤ 4 across display_first_name + ' ' + display_last_name, top 5 suggestions sorted by similarity desc. Threshold tuned to catch "Mueller / Müller", "Anna B. / Anna Bauer", "Lukas / Lukas Müller" without overwhelming with false positives. Alternative: lower threshold (≤ 2) for tighter matches; higher (≤ 6) for more aggressive suggestions. Stefan to confirm; the algorithm is a one-line config change.

  2. Logging "Cancel — they're different" rejections. Proposed default: do NOT log a share_audit row when the admin cancels. Reasoning: this is a no-op; cluttering the audit log with non-actions adds noise. Alternative: log every "considered but not merged" pair to enable a "we evaluated this pair on date X, decided different" lookup. Adds value for forensic debugging; adds noise everywhere else. Mira leans no-log.

  3. Surviving-Person default — admin-pick vs. preselect verified-email. Proposed default: admin-pick, with subtle visual hint (bg tint) on the verified-email pane. Forces the admin to look at both panes before deciding. Alternative: pre-select the verified-email pane as surviving, with a "switch" affordance (faster but reduces decision-quality). Mira leans admin-pick for a destructive operation.

  4. Typed-confirm strictness — exact match or fuzzy? Proposed default: exact match including special characters, case, and spacing. "Lukas Müller" must be typed with the umlaut. The destructive-action gate works because it requires deliberate input. Alternative: case-insensitive or accent-insensitive match (more forgiving but less safe). The strict pattern matches the stringer-lifecycle finalize convention. Stefan to confirm.

  5. Merge CTA color — red (destructive) or indigo (admin-action neutral)? Proposed default: red. The merge is destructive in the sense that one Person row becomes a tombstone and the other absorbs all references. Even though the data is preserved (per ADR-0004 § Person merge implementation A), the operation is consequential and irreversible without admin DB access. Red signals "consider before clicking". Alternative: indigo (admin actions in general are not red). Mira leans red.

  6. What-happens preview content — orders count via JOIN, or just ClientProfiles count? Proposed default: show both counts plus the share-grant count. Stefan thinks in orders ("4 orders move"); the JOIN is cheap; the value is high. Alternative: ClientProfiles only (technically what changes; mentally less concrete). Mira leans show-orders.

  7. Email-clearing on merging row's tombstone. Proposed default: yes — set email = NULL on the merging-row tombstone to release the email constraint and prevent silent re-conflation per the FADP positioning in ADR-0004 § Person merge. The surviving row keeps its email. Alternative: keep both emails (the merging tombstone would then have an email that no longer routes anywhere — confusing). Pax-B implementation question; flagged here for visibility.

  8. Already-merged Person handling on Stage 2 entry. Proposed default: render the "Already merged" overlay card with the prior merge details and offer the back-to-search escape. Stefan should not be able to chain a merge into a tombstone. Alternative: redirect to a different surface (e.g. the surviving Person's compare page). Adds complexity; the overlay is the simpler stop. Mira leans overlay.

  9. Bulk merge (more than two Persons in one transaction). Proposed default: not supported in V2. Bulk merge is a real V3 use case (e.g. Stefan finds three Persons all named "Lukas Müller" — two unverified + one verified). For V2, the admin merges them pairwise: A merge B; then (A surviving) merge C. Same outcome; one extra step. Alternative: design a 3+ Person merge surface now. Adds significant UI complexity; defer to V3 unless the duplicate volume justifies it.

  10. Recent-merges block — top 5 vs. top 10 vs. paginated. Proposed default: top 5 + "See all merges →" link to a paginated archive. The 5-row block matches the dashboard's audit-log-preview convention. Alternative: top 10 (more context; eats more vertical space). Mira leans 5.

  11. Suggested-duplicates initial render — server-side at page load, or lazy-loaded on user request? Proposed default: server-side at page load. The query is cheap (V2 scale: hundreds of Persons, not millions); the proactive surface is the value. Alternative: a "Show suggestions" button that triggers the query on demand. Adds a click; rare benefit. Stefan to confirm.

  12. Person-admin detail page (cross-link from the merge tool). Proposed default: not in Round 3 scope. A future surface where admin sees one Person's full timeline (orders + ClientProfiles + audit + shares + merges). Useful but not load-bearing for the merge use case; the Stage 2 compare panes already render the most important per-Person context. Queued for a later round.

  13. Provenance display — show creator stringer for every Person, even on Stage 1 search results. Proposed default: show creator on Stage 2 panes only. Stage 1 search results stay compact (name + email-status + counts); Stage 2 is the deep-dive. Alternative: always show creator (helps with disambiguation in Stage 1 search). Mira leans Stage-2-only.

  14. Re-running the duplicate-detection algorithm (V3+). Proposed default: not in Round 3. A future scheduled job that runs the suggested-duplicates query and notifies admin when high-similarity pairs appear. V2 reality: the surface auto-runs at page-open; that's enough. Alternative: a daily cron + dashboard chip. Defer.

  15. Merge undo (V3+). Proposed default: no undo in V2. Merges are intended to be permanent; the on-write fan-out makes "undo" expensive (re-fan-out the IDs back, hope no new orders were created against the surviving row). The typed-confirm + reason field is the upfront friction substitute for an undo. Alternative: an "undo last merge" affordance with a 24h window. Adds complexity; rare benefit. Mira leans no-undo; Stefan to confirm.