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 everyclient_profiles.person_id, everyorder_shares.granter_person_id(wheregranter_kind=person), everyperson_stringer_share.person_idfrommerged_person_idtosurviving_person_id, then markpersons.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_auditwithevent_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:
- 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.
- The audit trail is load-bearing. Every merge writes a
person_mergesrow AND ashare_auditrow. The UI surfaces the audit history of merges per Person so a future debugger can answer "did Stefan ever merge this person?". - 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.
Stage 1 — Search¶
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
Personrows wherelevenshtein(display_first_name || ' ' || display_last_name, …) < 4ANDmerged_into IS NULLAND not already in a recentperson_mergesrow. Top 5 suggestions, sorted by descending similarity. - Search input.
keyupHTMX swap of the result list (hx-trigger="keyup changed delay:200ms"). Searchesdisplay_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_mergesrows, 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¶
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_intoFK 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-50tint (vs. the barebg-whiteof the other) — surfaces "this is the more-authoritative-looking row" without preselecting. - "Pick A / B as surviving" buttons advance to Stage 3 with
surviving=:idandmerging=:other_idquery params. - Cancel — they're different. Returns to Stage 1 search; the candidate pair is logged as "considered but not merged" via a single
share_auditrow withevent_kind = person_merge_considered_rejectedfor 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¶
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_idPLUSperson_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_idfrommerged_person_idtosurviving_person_id. - UPDATE every
order_shares.granter_person_id(wheregranter_kind = 'person'ANDgranter_person_id = merged_person_id) tosurviving_person_id. - UPDATE every
person_stringer_share.person_idfrommerged_person_idtosurviving_person_id. - UPDATE
persons.merged_into = surviving_person_idon the merging row; setemail = NULLon the merging row to release the email constraint (per ADR-0004 §FADP positioning — clearing email prevents silent re-conflation). - INSERT one row in
share_auditwithevent_kind = person_merge,actor_kind = stringer(admin is a stringer withrole = 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:
- Stage 1 — Recent merges block (top 5 + link to archive).
- Stage 2 — Audit sub-block per Person pane (any prior merges involving this Person).
- 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-describedbylinking to the "what happens" list, so a screen-reader user gets the dry-run summary in context. - Typed-confirm input uses
aria-describedbylinking to the prompt; the disabled state of the Merge CTA is announced viaaria-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 usestext-amber-700on 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 with422and 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 viahx-redirectheader).
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
mdmay need pane width extended slightly in DE; reservemin-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_idfor provenance,share_audit.event_kindextension to includeperson_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_auditfor 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)¶
-
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. -
Logging "Cancel — they're different" rejections. Proposed default: do NOT log a
share_auditrow 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. -
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.
-
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.
-
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.
-
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.
-
Email-clearing on merging row's tombstone. Proposed default: yes — set
email = NULLon 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. -
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.