Engineering reference: the budget variance reporter 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 import discipline, and where the model is and is not used.
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
- 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 |
|---|---|---|---|
bv-import | S3 ObjectCreated | Digest check, period identification, whole-period replace | 120s / 1024 MB |
bv-analyse | SQS period queue | Comparison, the four timing tests, transaction attribution | 60s / 1024 MB |
bv-report | EventBridge monthly + SQS | One Bedrock call for the explanation sentences; sends the page | 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 |
|---|---|---|
bv-import-role | s3:GetObject, dynamodb:BatchWriteItem | The exports prefix; the periods table |
bv-analyse-role | dynamodb:Query/PutItem, secretsmanager:GetSecretValue | Periods and lines; the Sheets credential only |
bv-report-role | bedrock:InvokeModel, ses:SendEmail | One model arn; 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: periods
PK entity S main
SK period S 2026-07#v2
file_digest S sha256 of the export as received
imported_at S 2026-08-04T06:10:00Z
restatement_of S 2026-07#v1, or null
row_count N 3412
transactions S s3://exports/2026-07-v2.csv
A version suffix on the sort key is what makes restatements cheap: the
previous version is never deleted and the report can name what changed.
Table: lines
PK entity_period S main#2026-07#v2
SK code S 6210
name S Repairs & maintenance
budget N 4000.00
actual N 6400.00
variance N 2400.00
ytd_budget N 28000.00
ytd_actual N 29900.00
shape S flat | seasonal | calendar | rate
verdict S reported | timing
timing_reason S missing_regular | early | ytd_ok | reversal
filtered_run N how many consecutive periods filtered
top_items L [{supplier, amount, description}]
`filtered_run` is what promotes a variance explained away three months in
a row. A consistent filter is itself a finding.
Inbound and outbound
- Exports land in an S3 prefix, either dropped by a scheduled job in the accounting package or written by a small sync. The S3 event fires the import.
- The digest check comes first. An identical file is a duplicate and does nothing; a different file for a period already imported is a restatement.
- A period is replaced whole. There is no patch path, because accounting exports rarely carry stable row identifiers and a wrong diff is worse than a slow rewrite.
- Nothing is written back to the ledger, ever. The system has no credential with write access to the accounting package.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, used only to turn a variance and its top transactions into one readable sentence. - Called once per period, with the three or four surviving lines. It never sees the full transaction set.
- It does not decide anything. The comparison, the four timing tests and the attribution are all arithmetic, computed before the model is involved.
- Output is a JSON schema with one sentence per line, constrained to state only what is in the numbers it was given.
- No commentary. The prompt explicitly forbids judgement about whether a variance is acceptable, which is management’s call and not a model’s.
Things worth knowing before you build it
- Insist on transaction-level data. A trial balance gives you the variance and none of the reason, and the reason is the whole product.
- Shape the budget before tuning the thresholds. Most false variances come from a flat twelfth and no threshold setting will fix that.
- Tighten the year-to-date test as the year progresses. In month eleven a large monthly variance barely moves the year-to-date figure and the test stops working.
- List filtered variances rather than hiding them. A reader who cannot see what was removed will not trust what was kept.
- Promote a variance filtered three times running. A consistently correct filter is the most dangerous thing in the system.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts