Skip to content

Part 7 of 7 · Mileage claim checker series ~7 min read

Engineering reference: the mileage claim checker 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 and what each is allowed to touch, the three tables and their keys, the routing cache, and the specific model.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 5 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 mileage claim checker drawn with AWS service namesThree boxes across the top outside the AWS account. The Phone form, served as static files from S3 behind CloudFront. The Routing API, an external service returning a driving distance for a pair of coordinates. And SES outbound, carrying the questions and the receipts. Inside the account, three groups. A Function URL and a single claim queue. Five Lambda functions named intake, resolve, check, ask and pay. And three DynamoDB tables named claims, routes and totals. A note gives the region as us-east-1, one account, with Secrets Manager holding the routing provider key and the link-signing key.AWS ACCOUNTPhone formCloudFront + S3Routing APIdistance for a pairSES outboundquestions, receiptsAPI + SQSFunction URL,one claim queueLambda x5intake, resolve, check,ask, payDynamoDB x3claims, routes, totalsingroundsoutus-east-1. One account. Secrets Manager holds the routing key and the link-signing key.
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
  • 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
mc-intakeFunction URLValidates the submission, fingerprints it, enqueues one claim10s / 512 MB
mc-resolveSQS claim queueResolves both ends to coordinates; one routing call on a cache miss20s / 512 MB
mc-checkSQS resolved queueBand, repeat and pattern comparisons10s / 512 MB
mc-askSQS question queue + EventBridgeBuilds and sends the question; runs the escalation sweep15s / 512 MB
mc-payFunction URL + SQSHandles the signed answer links; writes the payment and the total10s / 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
mc-intake-roledynamodb:PutItem, sqs:SendMessageClaims table; the claim queue only
mc-resolve-roledynamodb:GetItem/PutItem, secretsmanager:GetSecretValueRoutes table; the routing key only
mc-check-roledynamodb:Query, sqs:SendMessageClaims and totals; two queues
mc-ask-roleses:SendEmail, sns:PublishOne verified identity; SMS to staff numbers only
mc-pay-roledynamodb:UpdateItem, secretsmanager:GetSecretValueClaims and totals; the signing key only

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: claims

PK   claim_id          S   clm_2026_07_09_a3d1
     status            S   open | asked | payable | exported
     fingerprint       S   sha256(claimant|trip_date|from_cell|to_cell)
     claimant          S   sam@example.com
     trip_date         S   2026-07-07
     from_cell         S   geohash7 of the origin
     to_cell           S   geohash7 of the destination
     miles_claimed     N   84.0
     miles_expected    N   41.2
     basis             S   expected 41.2, band +50%, return trip
     ttl               N   epoch, +7 years

GSI  fingerprint-index   PK fingerprint          — the repeat test
GSI  status-index        PK status, SK asked_at  — the escalation sweep

Table: routes

PK   pair              S   geohash7|geohash7, lexically sorted
     miles             N   41.2
     provider          S   which service answered
     fetched_at        S   2026-07-09T18:02:11Z

Sorting the two cells lexically means A-to-B and B-to-A share one cache
entry, which roughly halves the lookups on any out-and-back round.

Table: totals

PK   claimant          S   sam@example.com
SK   period            S   2026-07
     miles             N   412.0
     amount            N   185.40
     claims            N   9

The increment:
  UpdateExpression:    SET miles = miles + :m, amount = amount + :a
  ConditionExpression: attribute_not_exists(exported_at)

Inbound and outbound

  • The phone form is static files in S3 behind CloudFront, with an origin access control. There is no login: the link carries a signed staff token, minted once when somebody is added to the staff sheet, and it is the only credential a driver ever handles.
  • Function URLs are public by default. Both mc-intake and mc-pay verify an HMAC on the first line of the handler, before parsing and before any database read.
  • Answer links in the question email are signed, scoped to one claim, single-use by conditional write, and expire after fourteen days — longer than the approval links elsewhere on this site, because drivers take holidays.
  • SMS nudges go through SNS to numbers from the staff sheet only. There is no path by which a number in a claim body becomes a destination.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used only for resolving a typed place name against the customer and job lists. Everything numeric is code.
  • Called at most once per claim, and not at all when both ends came from the recent list or the customer search — which is most claims.
  • Output is a JSON schema with a customer id, a job id and a confidence, all nullable. Null produces the short pick-list the claimant sees; it never produces a guess.
  • Grounded with the claimant’s own recent sites and the active customer list, so the model chooses from a list rather than inventing an address.
  • No tools, no chaining. One call in, one JSON out.

Things worth knowing before you build it

  • Geohash precision is the whole repeat test. Seven characters is roughly 150 metres, which merges two spellings of one site without merging two units on one estate. Six is too coarse and eight is too fine.
  • Sort the two cells before building the routes key, or every out-and-back trip pays for two lookups.
  • Home-to-site mileage has tax consequences that differ by country. Put the rule in the sheet and never in code, because the person who knows the rule is your accountant.
  • A new SES identity is in the sandbox. Question emails to real staff need production access, which is a support request.
  • Stamp the rate on the payment record. A rate change in April will otherwise silently restate every unpaid claim from March.

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

All posts