How a form submission gets checked
The submission is saved and the customer already has their reply. Now the checker picks it up and decides three things: are the fields actually usable, is this a real person or spam, and what kind of request is it. The whole decision is plain Python with one small exception. No model on required fields. No model on the obvious-spam rules. A model is called only when a check is genuinely borderline — and even then, just for a second opinion, never to throw a lead away on its own.
Key takeaways
- The checker runs right after capture, reading the saved submission — the customer is already acknowledged.
- Spam rules live in the rules doc — honeypot field, minimum time-on-page, per-address rate limit, banned patterns.
- Four outcomes per submission: held, accepted, needs-category, or routed-ready.
- Anything flagged is filed in a held bucket for one-tap review — never silently deleted.
- Bedrock fires only on borderline spam and unknown categories. Most submissions never touch a model.
The decision flow, per submission
Required fields: usable beats present
The first check is the dull, important one: are the fields the form marked as required actually there and actually usable? A required email that’s blank, or that reads asdf, or a phone number that’s three digits — these aren’t leads you can act on. Rather than guess or auto-correct, the checker routes them to held with a note saying which field failed. A human glances at the held bucket, and most of the time it’s genuinely junk; occasionally it’s a real person who fat-fingered their email, and the held note tells the team exactly what to ask for.
Which fields are required, and what “well-formed” means for each, lives in the rules doc per form_id — so adding a required field to the booking form is a doc edit, not a code change.
Spam rules: cheap, layered, and biased toward keeping leads
The spam check runs as a stack of cheap deterministic rules, in order, all configured in the rules doc:
- Honeypot. A hidden field a human never sees and never fills in, but bots happily complete. If it’s filled, the submission is almost certainly a bot — held.
- Time-on-page. The snippet records how long the form was open. A submission completed in under a second or two is a script, not a person — held.
- Rate limit. More than a handful of submissions from the same address in a short window is a flood — the extras are held.
- Banned patterns. A short list in the rules doc — known spam phrases, link-stuffing, gibberish markers. A clear hit is held.
A submission that trips none of these passes as clean. A submission that clearly trips one is held. The interesting case is the in-between — a real-looking message that happens to mention a few risky words, or a slightly fast fill. For those, and only those, the checker makes one cheap Bedrock Haiku 4.5 call: “Here’s a website form submission. Is this a genuine inquiry or spam? Answer with a label and one line of reasoning.” The model’s answer is a second opinion that tips a borderline case, never the sole judge. And crucially, the worst outcome of the whole stack is held, not deleted — a wrongly-flagged real lead sits one tap away from being released, while a wrongly-passed spam costs the team ten seconds to ignore. The bias is deliberate: losing a real customer is far worse than seeing a junk one.
The category decision
Most forms answer “what kind of request is this?” by themselves. A quote form is a quote; a booking form is a booking. The routing table maps the form_id straight to a category and the checker is done. The exception is the generic catch-all contact form, where the same form might carry a sales question, a support problem, or a partnership pitch — and where it should go depends on what the customer actually wrote.
For those, the checker makes one Bedrock Haiku 4.5 call to read the message and propose a category from the short list in the rules doc (sales, support, billing, other). The submission sits at needs-category for the fraction of a second the call takes, then moves to routed-ready with the proposed category attached. If the model is unsure, it’s allowed to answer other, which routes to a general inbox a human triages — an honest “I’m not sure” beats a confident wrong guess.
Status that makes the path auditable
Every step updates the submission’s status in DynamoDB: received → checking → one of held, routed-ready. Each held submission carries a short held_reason (which field, which spam rule). This is what makes “what happened to that lead?” answerable: you can look up any submission and see exactly why it was held, or that it sailed through clean, with timestamps. No black box.
Why the hot path is mostly model-free
The checker could send every submission to a model and ask “is this good, and where should it go?” It doesn’t, for two reasons. First, the common case — a valid, clean, clearly-categorized submission — should be utterly predictable and instant; a model in that loop adds variance and latency for no gain. Second, model calls cost money, and the overwhelming majority of submissions are unambiguous, so the call would be wasted on most of them. Bedrock earns its place exactly twice: tipping a borderline spam call, and reading a generic contact message to guess a category. Everywhere else, plain Python reading a doc is faster, cheaper, and easier to reason about.
Next post: how a routed-ready submission finds the right tool — the routing table, the per-delivery queue, and the retries that mean a busy CRM never costs you a lead.
All posts