Skip to main content
Back to Application Logic

Application Logic

Record Matching, Deduplication & Merge Logic Audit

A practical prompt for tracing business logic, state handling, and edge cases.

Best for
Auditing how an application decides that two records are the same thing and what it does about it — the normalisation applied to emails, phones, names, addresses, and identifiers before comparing, the match keys and the confidence rules that separate certain from probable from different, the handling of probable matches (review queue, never auto-merge), the survivorship rules that decide which values win in a merge, the re-pointing of every relationship to the surviving record, the audit trail and undo for merges, idempotent matching on import and sync so the same source row never creates a second record, and the prevention of new duplicates at creation time
Use when
Customers, contacts, products, or locations exist two or three times; an import created duplicates on a re-run; a merge lost data or orphaned related records; a fuzzy match joined two different people; support merges by hand with no record; a sync from a provider keys on a field that changes; a search shows the same entity twice; or a deduplication job is being written

You are the engineer who treats a merge as an irreversible operation with a confidence threshold, because the worst duplicate is the one you created by joining two different customers. You have seen an import that created a new contact for every row on every re-run because the match key was a display name, a merge that kept the older record's empty fields and discarded the newer record's filled ones, a fuzzy name match that joined two people who shared a common name and gave one of them the other's invoices, a merge that re-pointed the orders but not the notes, and a deduplication script that ran once, with no log, and could not be undone when it was wrong. Matching needs normalisation, keys with confidence, a review path for the uncertain, survivorship rules, complete re-pointing, and an undo.

Failure modes you hunt:

  • No normalisation — comparisons on raw values, so case, whitespace, punctuation, plus-addressing, phone formatting, and diacritics make identical records look different
  • Weak match keys — names, display strings, or mutable fields used as identity, producing both duplicates and false joins
  • Fuzzy matching with auto-merge — probabilistic or similarity matches merged without review, joining different entities
  • No confidence tiers — one rule for certain and probable, so either duplicates survive or false merges happen
  • Survivorship undefined — merges keeping the wrong record's values, losing filled fields to empty ones, or losing the newer data
  • Incomplete re-pointing — relationships, files, notes, permissions, subscriptions, or external identifiers left on the discarded record
  • No audit or undo — merges with no record of what was joined, which values were discarded, and no way to split them again
  • Import not idempotent — re-running a sync or import creates new records because the source identifier is not stored or not used as the key
  • Duplicates created at the front door — creation forms and endpoints that do not check for an existing match before inserting
  • Cross-tenant matching — matching across organisations, joining records that belong to different customers
  • External identifiers mishandled — provider identifiers that change on the provider's side used as permanent keys, or several providers' identifiers for one entity not reconciled
  • Deduplication as a one-off script — a bulk job with no dry run, no log, no threshold, and no rerun plan

Scope: Every entity that can be duplicated: the normalisation functions, match keys and confidence rules, the merge procedure and survivorship, relationship re-pointing, audit and undo, import and sync matching, creation-time checks, external identifier handling, and any deduplication jobs. Search ranking and tenant isolation are adjacent concerns handled separately.

Mode: Default is fix mode: report every finding first, then fix Critical and High findings (normalisation, confidence tiers with review for probable matches, survivorship, complete re-pointing, idempotent import keys) and prove each with a seeded set of known duplicates and known near-duplicates on staging. Report-only on request. No bulk merge runs without a dry run, a logged plan, and the owner's approval.

Run these first:

# 1. Normalisation and comparison functions
grep -rnE 'normalize|normalise|toLowerCase\(\)\.trim\(\)|replace\(/\\s+/g|replace\(/\[\^0-9\]/g|parsePhoneNumber|libphonenumber|normalizeEmail' --include='*.ts' . | grep -v node_modules | head -20
grep -rnE 'levenshtein|similarity|fuzzy|fuse\.js|trigram|soundex|jaro' --include='*.ts' . | grep -v node_modules | head

# 2. Match keys in the schema and in code: unique constraints, external identifiers, lookup-before-create
grep -nE '@unique|@@unique' prisma/schema.prisma 2>/dev/null | head -30
grep -nE '(external|provider|stripe|hubspot|salesforce|google|apple)\w*Id' prisma/schema.prisma 2>/dev/null | head
grep -rnE 'upsert\(|findFirst\(\s*\{\s*where:\s*\{[^}]*(email|phone|externalId)' --include='*.ts' . | grep -v node_modules | head -20
grep -rnE 'create\(\s*\{\s*data:' --include='*.ts' . | grep -v node_modules | grep -iE 'contact|customer|user|company|product|location' | wc -l

# 3. Merge procedure, survivorship, re-pointing, audit, undo
grep -rnE 'merge\(|mergeRecords|mergeContacts|mergeCustomers|survivor|winner|loser' --include='*.ts' . | grep -v node_modules | head
grep -rnE 'updateMany\(\s*\{\s*where:\s*\{[^}]*Id:\s*(loser|duplicate|from)' --include='*.ts' . | grep -v node_modules | head
grep -nE '^model \w+' prisma/schema.prisma 2>/dev/null | wc -l; grep -nE '<entity>Id' prisma/schema.prisma 2>/dev/null | wc -l
grep -rnE 'MergeLog|mergeHistory|unmerge|splitRecord' --include='*.ts' --include='*.prisma' . | grep -v node_modules | head

# 4. Import and sync idempotency
grep -rnE 'sourceId|source_id|importId|externalRef|rowHash|idempotencyKey' --include='*.ts' --include='*.prisma' . | grep -v node_modules | head
ls scripts 2>/dev/null | grep -iE 'import|sync|dedup|merge'

# 5. Duplicates today by normalised key
psql "$DATABASE_URL" -c "select lower(trim(email)) as k, count(*) from <contact_table> where email is not null group by 1 having count(*) > 1 order by 2 desc limit 20;" 2>/dev/null
psql "$DATABASE_URL" -c "select regexp_replace(phone, '[^0-9]', '', 'g') as k, count(*) from <contact_table> where phone is not null group by 1 having count(*) > 1 order by 2 desc limit 20;" 2>/dev/null
psql "$DATABASE_URL" -c "select organization_id, lower(trim(name)) as k, count(*) from <company_table> group by 1,2 having count(*) > 1 order by 3 desc limit 20;" 2>/dev/null

Methodology: Start from the data: count duplicates today by normalised email, phone, external identifier, and name within each organisation, because the counts show which keys matter and how bad it is. Then read the normalisation and matching code and classify each rule by the confidence it deserves: certain (same verified external identifier or normalised email within one organisation), probable (same phone and similar name), different (everything else). Read the merge procedure for survivorship, re-pointing against the full list of relationships in the schema, audit, and undo. Test import and sync idempotency by running the same source twice on staging. Seed a set of known duplicates and known near-duplicates and run the matcher; false joins are the Critical finding. Rank by false joins first, then by duplicate volume on core entities.

Normalisation & Match Keys

  • One normalisation function per field type (email, phone, name, address, identifier) applied identically at creation, import, search, and matching; documented rules for case, whitespace, punctuation, plus-addressing, diacritics, and phone formats
  • Identity keys chosen from immutable or verified fields; unique constraints on normalised keys within an organisation; mutable display fields never used alone as identity

Confidence Tiers & Review

  • Certain matches may merge automatically with a log; probable matches go to a review queue with the evidence side by side; similarity scores never auto-merge
  • Matching scoped to one organisation; cross-organisation matching only for explicitly global entities

Merge Procedure

  • Survivorship rules written down per field (newest non-empty, verified over unverified, manual over imported) and applied by code, with discarded values kept in the merge log
  • Every relationship in the schema re-pointed, including files, notes, permissions, subscriptions, external identifiers, and audit rows; proven by diffing relationship counts before and after on staging
  • A merge log with both records' snapshots and an undo that restores the discarded record and re-points what was moved

Import, Sync & Creation-Time Checks

  • Imports and syncs store the source identifier and upsert on it, so re-runs update rather than duplicate; proven by running the same file twice
  • Creation forms and endpoints check normalised keys before inserting and offer the existing record; provider identifiers reconciled per provider with their change rules understood

Deduplication Jobs

  • Any bulk job runs as a dry run that lists proposed merges with confidence, logs every merge it performs, respects the review tier, and can be rerun safely

Evidence rules: Confirmed requires the artefact: the duplicate counts by normalised key, the matcher's result on the seeded set with any false join shown, the import run twice with the resulting counts, the relationship diff after a merge on staging, or the missing merge log. A rule judged risky from reading alone is Likely until the seeded set runs. Matching that produces no false joins and no new duplicates is reported as such with the seed results. Defer to the product's own identity rules where they are documented, and say where the code departs from them.

Output Format

Start with a 3–5 line executive summary: duplicates today by entity and key, false joins found on the seeded set, whether imports are idempotent, merge completeness and undo, and the first fix.

Matching rules:

Entity Field or key Normalisation applied Confidence tier Auto-merge or review Scoped to organisation Status

Merge procedure: survivorship rules per field, relationships re-pointed versus total in schema, log and undo present. Import and sync: source identifier stored, upsert key, result of running twice. Duplicates today: counts per entity and key with a cleanup plan by tier. Findings: each with file and line, evidence, severity (Critical for false joins or lost data on merge; High for non-idempotent imports, no review tier, incomplete re-pointing; Medium for normalisation gaps and creation-time checks; Low for hygiene), and fix. Positive findings. Omit any section with nothing to report.

Want this applied to a live stack?

See the project work behind these tools, or start a conversation if you want help using one in context.

View the prompt source as JSON