Engineering reference: the form spam filter 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 one table, the signal collection, and the narrow model band.
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.
- 1 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
- Compute
- Database
- App integration
- Networking
- Front-end & mobile
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
devand aprodstack 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
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
fs-submit | Function URL | Accepts the post, captures the signals, enqueues; always returns success | 10s / 512 MB |
fs-score | SQS scoring queue | Cheap signals, then one Bedrock call for the middle band only | 20s / 512 MB |
fs-review | EventBridge 11am/4pm + Function URL | Builds the review batch; handles the two buttons; the 4-hour timeout | 20s / 512 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
| Role | Allowed | On |
|---|---|---|
fs-submit-role | dynamodb:PutItem, sqs:SendMessage | The submissions table; the scoring queue |
fs-score-role | bedrock:InvokeModel, dynamodb:UpdateItem, ses:SendEmail | One model arn; submissions; one verified identity |
fs-review-role | dynamodb:Query/UpdateItem, ses:SendEmail | Submissions; one verified identity |
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: submissions
PK submission_id S sub_2026_08_01_7f2a
received_at S 2026-08-01T10:14:02Z
fields M {name, email, phone, message}
signals M {honeypot, fill_ms, field_order, links,
consistency, body_hash}
cheap_score N 0.62
model_called BOOL true
model_specific N 0.71 — null if not called
destination S inbox | review | quarantine
reviewed_as S real | spam — set by a person
delivered_at S set when it reaches the inbox
ttl N quarantine: +90 days; real: none
GSI body-hash-index PK body_hash — the repetition check
GSI destination-index PK destination, SK received_at — the review batch
GSI email-index PK email — the quarantine search
Every submission is stored with its signals, which is what makes threshold
replay possible: a proposed change can be run over three months of real
traffic before it goes live.
Inbound and outbound
- The form is static files in S3 behind CloudFront, posting to a Function URL. There is no CAPTCHA and no third-party script.
- The honeypot field is hidden with CSS and removed from the accessibility tree, with
autocomplete=“off”and a name browsers will not autofill. Getting this wrong silently classifies screen reader users as bots. - Fill time is measured from a timestamp written into the page at render, signed so it cannot be forged, with a floor and deliberately no ceiling.
- The endpoint always returns success, whatever the classification. Telling a bot it was filtered is telling a bot how to adapt, and telling a person their genuine enquiry was rejected is worse.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, asked one question about roughly one submission in ten. - The prompt carries the message body only. Not the name, the address, the IP or the country — those are rule inputs, and putting them in a model prompt invites judgements about people based on where they are.
- Output is a JSON schema with a specificity score and a confidence. It never returns a destination, because the mapping from evidence to outcome is a business threshold.
- A model failure routes to review, never to quarantine. The band exists because the cheap signals could not decide, so falling back to them is falling back to a shrug.
- The band is rate-limited per hour. A spam flood must not become a Bedrock bill; overflow goes to review, which is the safe direction.
Things worth knowing before you build it
- Hide the honeypot from assistive technology, not just visually. An off-screen labelled field gets filled in by screen reader users and silently classifies them as bots.
- Put a floor on fill time and no ceiling. Somebody opens the form, takes a phone call, and submits forty minutes later.
- Always return success to the submitter. A rejection message teaches bots and insults people.
- Never delete. The searchable quarantine is the only way a false positive ever becomes visible.
- Store every signal with every submission. Threshold changes should be replayed against three months of real traffic before they go live, and that is only possible if the inputs were kept.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts