Skip to content

Part 7 of 7 · Budget variance reporter series ~7 min read

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

The budget variance reporter drawn with AWS service namesThree boxes across the top outside the AWS account. The Actuals export, arriving as a scheduled CSV or through an API. The Budget sheet, read through the Google Sheets API read-only. And SES outbound, carrying the monthly report. Inside the account, three groups. S3 holding the exports and EventBridge carrying a monthly schedule. Three Lambda functions named import, analyse and report. And two DynamoDB tables named periods and lines. A note gives the region as us-east-1, one account, and states the system is read-only against the ledger and never writes anything back.AWS ACCOUNTActuals exportscheduled CSV or APIBudget sheetSheets API, read-onlySES outboundthe monthly reportS3 + EventBridgeexports,monthly scheduleLambda x3import, analyse,reportDynamoDB x2periods, linesingroundsoutus-east-1. One account. Read-only against the ledger; nothing is ever written back.
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
  • 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 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
bv-importS3 ObjectCreatedDigest check, period identification, whole-period replace120s / 1024 MB
bv-analyseSQS period queueComparison, the four timing tests, transaction attribution60s / 1024 MB
bv-reportEventBridge monthly + SQSOne Bedrock call for the explanation sentences; sends the page30s / 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
bv-import-roles3:GetObject, dynamodb:BatchWriteItemThe exports prefix; the periods table
bv-analyse-roledynamodb:Query/PutItem, secretsmanager:GetSecretValuePeriods and lines; the Sheets credential only
bv-report-rolebedrock:InvokeModel, ses:SendEmailOne 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:0 on 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