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
- 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 |
|---|---|---|---|
mc-intake | Function URL | Validates the submission, fingerprints it, enqueues one claim | 10s / 512 MB |
mc-resolve | SQS claim queue | Resolves both ends to coordinates; one routing call on a cache miss | 20s / 512 MB |
mc-check | SQS resolved queue | Band, repeat and pattern comparisons | 10s / 512 MB |
mc-ask | SQS question queue + EventBridge | Builds and sends the question; runs the escalation sweep | 15s / 512 MB |
mc-pay | Function URL + SQS | Handles the signed answer links; writes the payment and the total | 10s / 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 |
|---|---|---|
mc-intake-role | dynamodb:PutItem, sqs:SendMessage | Claims table; the claim queue only |
mc-resolve-role | dynamodb:GetItem/PutItem, secretsmanager:GetSecretValue | Routes table; the routing key only |
mc-check-role | dynamodb:Query, sqs:SendMessage | Claims and totals; two queues |
mc-ask-role | ses:SendEmail, sns:Publish | One verified identity; SMS to staff numbers only |
mc-pay-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue | Claims 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-intakeandmc-payverify 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:0on 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