Skip to content

Part 7 of 7 · Tax rate updater series ~7 min read

Engineering reference: the tax rate updater 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 fetch discipline, and the one model call.

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 tax rate updater drawn with AWS service namesThree boxes across the top outside the AWS account. Official sources, fetched over HTTPS, some of them PDFs. The Register sheet, read through the Google Sheets API read-only. And SES outbound, carrying the notices and reminders. Inside the account, three groups. S3 holding content-addressed snapshots, and EventBridge carrying two schedules. Three Lambda functions named check, notify and heartbeat. And two DynamoDB tables named rates and changes. A note gives the region as us-east-1, one account, with outbound HTTPS only and nothing inbound except the checklist answer links.AWS ACCOUNTOfficial sourcesHTTPS, some PDFsRegister sheetSheets API, read-onlySES outboundnotices, remindersS3 + EventBridgesnapshots,two schedulesLambda x3check, notify,heartbeatDynamoDB x2rates, changesingroundsoutus-east-1. One account. Outbound HTTPS only; nothing inbound except the answer links.
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
  • Outside AWS

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
tr-checkEventBridge dailyFetches each source, snapshots, digests, reads on change120s / 1024 MB
tr-notifySQS change queue + EventBridge dailyAnnouncements, reminders, escalations and the annual review30s / 512 MB
tr-heartbeatEventBridge daily, separate ruleAlarms if any register row has not been checked in three days10s / 256 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
tr-check-roles3:PutObject, bedrock:InvokeModel, secretsmanager:GetSecretValueThe snapshots prefix; one model arn; the Sheets credential
tr-notify-roleses:SendEmail, dynamodb:UpdateItemOne verified identity; rates and changes
tr-heartbeat-roledynamodb:Scan, sns:PublishThe rates table; the operations topic 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: rates

PK   rate_id           S   standard-vat
     label             S   Standard VAT rate
     value             N   0.20
     format            S   percent | amount | threshold
     source_url        S   https://...
     source_kind       S   html | pdf | feed | manual
     find_label        S   the standard rate percentage
     effective_from    S   2011-01-04
     used_in           L   [{place, locator}]
     owner             S   finance@example.com
     last_checked      S   2026-07-15T06:00:00Z
     last_digest       S   sha256 of the last fetch

`last_checked` is what the heartbeat scans. A row that has not moved in
three days is an alarm regardless of what the rest of the system thinks.

Table: changes

PK   change_id         S   chg_standard-vat_2026-10-01
     rate_id           S   standard-vat
     old_value         N   0.20
     new_value         N   0.22
     effective_from    S   2026-10-01
     date_certainty    S   stated | assumed_immediate
     announced_at      S   2026-03-14
     snapshot_key      S   s3://snapshots/sha256...
     transitional      S   verbatim text, or null
     superseded_by     S   another change_id, or null
     checklist         L   [{place, ticked_by, ticked_at}]
     reminder_at       S   2026-09-03

GSI  reminder-index      PK reminder_at   — the daily notify sweep

Inbound and outbound

  • Outbound only, apart from the checklist links. There is no inbound mail path and no webhook, which removes an entire class of surface.
  • Fetches identify themselves with a real user agent naming the business and a contact address. Government sites are generally tolerant of this and hostile to anything that looks like an anonymous scraper.
  • Conditional requests are used where the source supports them: an ETag or Last-Modified turns most daily checks into a 304 that costs nothing and confirms the source is still reachable.
  • Checklist links are signed, scoped to one change and one place, and expire sixty days after the effective date.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock. The task is finding a labelled value in page text, which is extraction.
  • Called only when the bytes changed. An unchanged digest short-circuits before any model call, which is why this system costs almost nothing.
  • Output is a JSON schema with the value, an effective date and any transitional text, all nullable. A null value is a Label-not-found alarm and never a zero.
  • Grounded with the label from the register row, so the model is looking for a specific described thing rather than summarising a page.
  • Transitional text is returned verbatim and never summarised. Summarising a transitional rule is how a business confidently does the wrong thing at scale.

Things worth knowing before you build it

  • Anchor on the label, not on markup. A CSS path breaks on the first redesign; “the standard rate percentage” survives one.
  • Treat a missing label as an alarm, not as an unchanged value. A page that stops describing something is a real event.
  • Give the heartbeat its own EventBridge rule and its own function. A watcher cannot report that it is not running.
  • Store snapshots content-addressed. Three hundred and sixty-five daily fetches of an unchanged page should cost one object.
  • Never summarise transitional rules, and never let the system apply one. Extract them verbatim, attach them, and let an accountant read them.

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

All posts