Engineering reference: the utility bill watcher 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 two tables, the scheduled sweeps, and the specific model.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 4 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
- Compute
- Storage
- Database
- App integration
- Analytics
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 |
|---|---|---|---|
ub-intake | SES receipt rule + S3 ObjectCreated | Stores the PDF, fingerprints the bill, enqueues one message | 10s / 512 MB |
ub-read | SQS bill queue | Textract, then one Bedrock call into five fields plus the total check | 120s / 1024 MB |
ub-compare | SQS read queue | Meter match, three comparisons, history write | 10s / 512 MB |
ub-sweep | EventBridge daily | Overdue meters and contract ends at T-90 and T-30 | 30s / 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 |
|---|---|---|
ub-intake-role | s3:PutObject, sqs:SendMessage | The bills prefix; the bill queue only |
ub-read-role | textract:AnalyzeDocument, bedrock:InvokeModel | The bills prefix; one model arn |
ub-compare-role | dynamodb:PutItem/Query, secretsmanager:GetSecretValue | Bills and history; the Sheets credential only |
ub-sweep-role | dynamodb:Query, ses:SendEmail | History, read; 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: bills
PK fingerprint S sha256(supplier|meter|period_start|period_end)
meter S 1200034557
supplier S as printed, normalised
state S read | matched | unmatched | queried
fields M {usage, unit_rate, standing, estimated, total}
total_check S ok | mismatch
pdf_key S s3://bills/2026/07/...
ttl N epoch, +7 years
The fingerprint is the partition key, so a resent bill is rejected by the
conditional write rather than by a query that can race.
Table: history
PK meter S 1200034557
SK period_end S 2026-07-10
days N 30
usage N 4180.0
usage_per_day N 139.3
unit_rate N 0.241
standing N 0.48
estimated BOOL false
baseline_ok BOOL true
A year-on-year comparison is a single Query with a SK BETWEEN over the
same window last year, filtered to baseline_ok. No scan, ever.
Inbound and outbound
- An SES receipt rule set on the bills domain writes the whole message to S3, attachments included. The S3 event is what fires the intake; there is no SNS hop.
- Portal downloads go to a second S3 prefix that a person or a small sync drops files into. Both prefixes fire the same intake function.
- Spam and virus verdicts are on the SES headers written into the object, and the intake drops anything failing either before parsing.
- Suppression links in a finding message are signed, scoped to one meter and one finding shape, and expire after sixty days.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock. The task is picking five values out of a Textract layout, which is extraction. - Called once per new bill, after the duplicate test, never on a resend.
- Output is a JSON schema with every field nullable. A null usage produces a question with the page attached; it never produces a zero.
- Grounded with your meter numbers and their units, so the model matches to a known meter rather than reporting whatever string looks most like an identifier.
- The total check is code. Recomputing usage times rate plus standing charge times days and comparing with the printed total is arithmetic, and arithmetic should not go near a model.
Things worth knowing before you build it
- Store rates excluding tax and be strict about it. Comparing an inclusive rate against an exclusive contract produces a false alarm every single month.
- Take the unit from your meter list, not from the bill. Gas appears in cubic metres, kWh and therms, and a silent unit change makes a year of history meaningless.
- Normalise to per-day before comparing. Billing periods vary by several days and comparing raw totals generates constant noise.
- Quarantine estimated readings from the baseline, and spread a catch-up bill across the days it covers before recomputing per-day figures.
- Building the meter list is the hard part and it is not a software problem. Expect to find at least one supply nobody remembered, which is usually where the project pays for itself.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts