Skip to content

Part 7 of 7 · Abandoned form recoverer series ~7 min read

Engineering reference: the abandoned form recoverer 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 two tables, the capture allow-list, and the holdout assignment.

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.
  • 2 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 abandoned form recoverer drawn with AWS service namesThree boxes across the top outside the AWS account. The forms, sending step events by beacon. The Consent record from Day 105. And SES outbound, carrying one message. Inside the account, three groups. A Function URL for step capture and EventBridge running a detection sweep. Three Lambda functions named capture, detect and send. And two DynamoDB tables named sessions and outcomes. A note gives the region as us-east-1, one account, states that captured fields are an allow-list, and that partial data expires in seven days.AWS ACCOUNTThe formsstep events by beaconConsent recordfrom Day 105SES outboundone messageFunction URL + EventBridgestep capture,detection sweepLambda x3capture, detect, sendDynamoDB x2sessions, outcomesingroundsoutus-east-1. One account. Captured fields are an allow-list; partial data expires in 7 days.
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
  • Database
  • App integration
  • Networking
  • Security & identity
  • 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 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
af-captureFunction URLAccepts a step event; stores only allow-listed fields5s / 512 MB
af-detectEventBridge every 10 minutesFinds quiet sessions, runs the four basis gates, assigns the holdout30s / 512 MB
af-sendSQS send queueRe-checks completion, checks hours, sends once, records the outcome15s / 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

RoleAllowedOn
af-capture-roledynamodb:UpdateItemThe sessions table only
af-detect-roledynamodb:Query, sqs:SendMessage, secretsmanager:GetSecretValueSessions; the send queue; the consent resolver credential
af-send-roledynamodb:UpdateItem, ses:SendEmailSessions and outcomes; 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: sessions

PK   session_id        S   a random id from the form, not a customer id
     form_id           S   quote_bathroom
     steps_done        N   4
     steps_total       N   5
     fields            M   allow-listed fields only
     email             S   only if the field was on the allow-list
     email_label       S   the label shown next to it, verbatim
     last_step_at      S   2026-08-10T14:00:00Z
     completed_at      S   set on submission; cancels everything
     basis             S   ok | no_label | too_early | withdrawn | not_checked
     holdout           BOOL assigned at detection, before the outcome
     ttl               N   epoch, +7 days

`email_label` is stored verbatim because the basis in Part 3 depends on
what the person was actually shown, and form copy changes.

Table: outcomes

PK   form_id           S   quote_bathroom
SK   period_session    S   2026-Q3#session_id
     holdout           BOOL
     messaged          BOOL
     returned          BOOL   — did they complete within 7 days
     opted_out         BOOL
     complained        BOOL

No personal data at all. This survives the 7-day expiry of the session
and is what the holdout comparison is computed from.

Inbound and outbound

  • Step events arrive by beacon on step completion, not on input. There is no keystroke listener anywhere in the design.
  • Captured fields are an allow-list defined per form. A field added to a form is not captured until somebody adds it, which is the correct default.
  • Payment, password and special-category fields cannot be added to the allow-list; the capture function rejects them by name pattern as a second line of defence.
  • The consent resolver is consulted before every send, so a withdrawal recorded anywhere in the business suppresses this message too.

The model call

  • There is no model in this system. Detection is a timestamp comparison and the basis check is four rules.
  • The tempting use is generating the message, and it is the wrong one: the specificity comes from the captured fields, and a generated version reads as automated in exactly the way the wording is trying to avoid.
  • The template carries the specifics — the form name, the step, the location — substituted from what was captured.
  • A second tempting use is scoring how likely somebody is to convert, in order to message only the promising ones. That optimises the wrong thing and makes the holdout comparison invalid.
  • The cost page assumes none, which is why messaging is the only variable band.

Things worth knowing before you build it

  • Store the field label verbatim. The basis depends on what the person was shown, and form copy changes without anybody thinking about this system.
  • Re-check completion at send time. Sending a you-did-not-finish message to somebody who finished is the worst possible output.
  • Assign the holdout at detection, before the outcome is known, and never message it. Retrospective assignment produces a number worse than no number.
  • Expire partial data on a short clock. A table of half-finished forms is a data question you did not intend to take on.
  • When complaints appear, tighten the basis rather than softening the wording. The message is almost never the problem.

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

All posts