Skip to content

Part 7 of 7 · Duplicate contact merger series ~7 min read

Engineering reference: the duplicate contact merger architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the three tables, the merge transaction, and the undo bundle.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 3 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The duplicate contact merger drawn with AWS service namesThree boxes across the top outside the AWS account. Contact source, a CRM API read and written. Field rules, read through the Google Sheets API read-only. And SES outbound, carrying merge proposals and the monthly summary. Inside the account, three groups. S3 holding undo bundles and EventBridge providing a weekly run. Three Lambda functions named block, score and merge. And three DynamoDB tables named pairs, decisions and merges. A note gives the region as us-east-1, one account, and states that the only write to the CRM is a merge a person confirmed.AWS ACCOUNTContact sourceCRM API, read and writeField rulesSheets API, read-onlySES outboundproposals, monthlyS3 + EventBridgeundo bundles,weekly runLambda x3block, score, mergeDynamoDB x3pairs, decisions,mergesingroundsoutus-east-1. One account. The only write to the CRM is a merge a person confirmed.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Storage
  • Database
  • App integration
  • People

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
dm-blockEventBridge weeklyComputes four keys per record, forms blocks, caps oversized ones300s / 3008 MB
dm-scoreSQS block queueRarity-weighted evidence for and against; writes candidate pairs60s / 1024 MB
dm-mergeFunction URLBuilds the proposal; on confirmation writes the undo bundle then merges60s / 1024 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
dm-block-rolesecretsmanager:GetSecretValue, dynamodb:PutItemThe CRM read credential; the pairs table
dm-score-roledynamodb:Query/UpdateItemPairs and decisions
dm-merge-roles3:PutObject, secretsmanager:GetSecretValue, dynamodb:PutItemThe undo prefix; the CRM write credential; the merges table

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: pairs

PK   pair_key          S   min(id_a,id_b)#max(id_a,id_b)
     score             N   4.8
     terms             L   [{field, agreed, rarity, weight}]
     against           L   [{reason, strength}]
     band              S   automatic | propose | record_only
     blocked_by        L   which keys produced this pair
     ttl               N   epoch, +30 days

The pair key is order-independent, so A-B and B-A are one row. Getting that
wrong doubles every count and proposes each pair twice.

Table: decisions

PK   pair_key          S   min#max
     decision          S   merged | not_a_duplicate
     decided_by        S   a person, or ’automatic’
     decided_at        S   2026-08-05T11:02:00Z

No TTL, ever. A pair somebody has declared separate must never be proposed
again — that is the single feature that keeps the review worth opening.

Table: merges

PK   merge_id          S   mrg_2026_08_05_c41a
     kept_id           S   the surviving record
     merged_id         S   the record that was absorbed
     bundle_key        S   s3://undo/mrg_....json
     moved             L   [{type, id, from, to}]
     undoable_until    S   2026-11-03
     undone_at         S   or null

`moved` is the complete list of related records repointed, in both
directions, which is what makes an undo a restore rather than a guess.

Inbound and outbound

  • The CRM read credential and the write credential are separate secrets. Only dm-merge can read the write credential, and only on a confirmed proposal.
  • Confirmation links are signed, scoped to one pair, single-use, and expire after thirty days — the same window as the pairs table, so a stale link cannot act on a recomputed pair.
  • The undo bundle is written before the merge, not after. A merge that fails part way through leaves related records split across both, and the bundle is what puts them back.
  • Merges are serialised per record. Two proposals involving the same record cannot execute concurrently; a conditional write on the record id enforces it.

The model call

  • There is no model in this system. Blocking is key computation, scoring is weighted evidence, and both need to be identical between runs.
  • An embedding-based similarity is the obvious alternative and it produces the thing this design specifically avoids: a single opaque number instead of visible evidence.
  • The proposal has to be explainable in the four seconds somebody spends on it, and “same phone, same rare surname, different first name” is explainable in a way that 0.88 is not.
  • Rarity weighting gives most of what a learned model would, from a count query over your own data, and it is stable between runs.
  • The cost page assumes none, which is why compute is the only variable band.

Things worth knowing before you build it

  • Make the pair key order-independent. A-B and B-A as separate rows doubles every count and proposes each pair twice.
  • Cap block sizes. One placeholder phone number shared by four hundred records reintroduces the quadratic problem blocking exists to solve.
  • Never expire the not-a-duplicate decisions. Re-proposing rejected pairs is what makes people stop opening the review.
  • Store full snapshots, not diffs. An undo applied to a record somebody has since edited must restore, not reconstruct.
  • Write the undo bundle before the merge runs, so a partial failure is recoverable with the same mechanism.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts