Engineering reference: the lead enricher architecture
This is the enricher with the friendly labels stripped off. Same system, drawn for someone who has to build or review it: the Lambdas and what triggers each one, the EventBridge schedules, the DynamoDB identity index and its exact key schema, the S3 buckets, the SES setup, the IAM scopes that keep each function to its own job, the Bedrock model id, and the single region it all runs in. If the earlier posts are the “what” and the “why”, this one is the “how, exactly.”
Key takeaways
- Seven Lambdas (Python 3.14, arm64): one per lead lane and one per pipeline stage, chained over the EventBridge default bus.
- Two DynamoDB tables on-demand:
ler-identity(the dedupe index) andler-enrich-cache(per-domain firmographics with a TTL). - Three S3 buckets: raw payloads (versioned), clean enriched records, and the junk log. SES handles the inbound email lane and the outbound digest.
- EventBridge Scheduler runs three jobs: a retry sweep, an overnight backfill + identity re-sync, and the daily junk digest.
- One Bedrock model (
global.anthropic.claude-haiku-4-5-20251001-v1:0), one region (eu-west-2), least-privilege IAM per function, no API Gateway and no always-on compute.
The whole architecture, drawn for engineers
Same system as the earlier posts, with the friendly labels off. Three ingress lanes, a five-stage event-driven chain, two outputs, and three scheduled jobs — all in one region.
Lambdas
All Python 3.14 on arm64, 256–512 MB, with a dead-letter queue (ler-dlq) on every asynchronous path.
ler-intake— the front door. Triggered three ways: a Lambda Function URL (IAM-auth or a shared secret) for the form/webhook lane, an S3 PUT onler-rawfor the manual-upload and SES-inbound lanes. Writes the raw payload toler-raw(versioned), runs the cheap spam pre-screen (honeypot, disposable domain, timing), drops obvious bot junk straight toler-junk, and enqueues survivors to the SQS queueler-intake-queue. Memory: 256 MB.ler-normalize— consumesler-intake-queue(batch size 1). Deterministic field cleanup (email, phone → E.164, name casing, country); calls Bedrock Haiku 4.5 only for flagged-ambiguous fields, bounding the result against ISO/reconstruction rules. Writes the clean record and emitsle.normalizedto the EventBridge default bus. Memory: 512 MB.ler-enrich— EventBridge rule onle.normalized. Skips personal/free domains; reads/writesler-enrich-cache; on a miss, fetches the firmographic key from Secrets Manager and calls the provider, writing the result back to the cache with a TTL. Tags each field with source + date. Emitsle.enriched(or marksenrichment_pendingon provider failure and still emits). Memory: 256 MB. Timeout: 30 s.ler-dedupe— EventBridge rule onle.enriched. Exact-key lookup againstler-identity; deterministic fuzzy scoring for near-matches; Bedrock Haiku 4.5 only for the ambiguous band. Emitsle.dedupedcarrying the verdict (neworlink:<crm_id>). Memory: 256 MB.ler-filter— EventBridge rule onle.deduped. Weighs deterministic junk signals; Bedrock Haiku 4.5 only for borderline cases. Emitsle.cleanorle.junk. Memory: 256 MB.ler-push— EventBridge rule onle.clean. Does one final authoritative duplicate check against the live CRM (backstop to the index), then creates or links the contact via the CRM API (key in Secrets Manager), filling only empty fields — never overwriting. Writes the clean record toler-recordsand updatesler-identity. Memory: 256 MB. Timeout: 30 s.ler-junk-writer— EventBridge rule onle.junk. Appends the dropped record and its reason toler-junk. Memory: 128 MB.
EventBridge Scheduler
ler-retry— every 15 minutes. Re-drives messages fromler-dlqand retries any lead markedenrichment_pendingfrom a transient provider failure.ler-backfill— nightly (03:00). Re-enriches still-pending leads once the provider has recovered, and re-syncsler-identityfrom a fresh CRM export so the index doesn’t drift from the source of truth.ler-digest— daily (08:00). Summarises the previous day’sler-junkdrops and emails the digest via SES so a human watches the boundary.
DynamoDB
ler-identity(on-demand) — the dedupe/identity index. Partition key:id_key(string) — a typed, normalised key such asEMAIL#jane.doe@acme-widgets.co.uk,PHONE#+447700900123, orFP#janedoe|acmewidgets. Attributes:crm_id,person_name,company,source,updated_at. A global secondary index oncrm_idlets the backfill rewrite every key for a person in one pass. Multipleid_keyrows can point at the samecrm_id— that’s how one person is reachable by email, phone, or fingerprint.ler-enrich-cache(on-demand) — per-domain firmographic cache. Partition key:domain(string). Attributes:profile(JSON),fetched_at,ttl(epoch seconds; DynamoDB TTL enabled, ~90 days). A miss or an expired item triggers a fresh provider call.
S3 & SES
- S3
ler-raw— raw inbound payloads (form JSON, SES MIME, uploaded CSVs). Versioning on; the immutable original behind every lead. - S3
ler-records— the clean, enriched, deduped record written for each lead that reached the CRM. - S3
ler-junk— the junk log: every dropped lead with its reason. The replay source if the filter ever drops something real. - SES inbound — rule set for
hello@your-company.com, action S3 PUT toler-raw, which triggersler-intake. - SES outbound — the daily junk digest and ad-hoc ops alerts (provider down, DLQ backing up).
Bedrock
- Foundation model.
anthropic.claude-haiku-4-5-20251001-v1:0, invoked via the Global cross-Region inference profileglobal.anthropic.claude-haiku-4-5-20251001-v1:0. Three callsites:ler-normalize(fuzzy field resolution),ler-dedupe(ambiguous same-person judgement), andler-filter(borderline keep-or-drop). Each call is narrow, structured, and bounded by deterministic rules afterwards. - Heavier model.
anthropic.claude-sonnet-4-6-...is configured but off by default — available behind a per-callsite flag if a particular dedupe or junk decision ever needs more judgement than Haiku gives. The match and filter logic never depends on a model; the model only ever breaks a tie.
IAM (least privilege per function)
ler-intakerole:s3:PutObject/GetObjectonler-rawandler-junk;sqs:SendMessageonler-intake-queue. No Bedrock, no DynamoDB.ler-normalizerole:sqs:ReceiveMessage/DeleteMessageonler-intake-queue;s3:GetObject/PutObjectonler-raw/ler-records;bedrock:InvokeModelon the Haiku ARN;events:PutEvents.ler-enrichrole:dynamodb:GetItem/PutItemonler-enrich-cache;secretsmanager:GetSecretValueon the enrichment-key secret only;events:PutEvents. No write access toler-identity.ler-deduperole:dynamodb:Query/GetItemonler-identity(read);bedrock:InvokeModelon the Haiku ARN;events:PutEvents. No CRM credentials.ler-filterrole:bedrock:InvokeModelon the Haiku ARN;events:PutEvents. No data-store writes.ler-pushrole:secretsmanager:GetSecretValueon the CRM-key secret;dynamodb:PutItem/UpdateItemonler-identity;s3:PutObjectonler-records. The only role that can write the identity index or talk to the CRM.
Region & operations
- Region. Everything in
eu-west-2(London). The Bedrock Global cross-Region inference profile routes the model call for capacity, but no data store, queue, or bucket leaves the region. - CloudWatch Logs. Every Lambda logs at 7-day retention — enough to debug a bad day, short enough to stay free-tier-adjacent.
- DLQ.
ler-dlq(SQS) catches any event that fails twice;ler-retryre-drives it and alerts via SES if it backs up. - AWS Budgets. A monthly cost alarm (first two budgets free) on the account, posting to SNS, so a runaway loop or a provider price change is caught the same day.
- No API Gateway, no NAT Gateway, no always-on compute. Ingress is a Lambda Function URL plus SES and S3 events; everything else is event- or schedule-driven.