Engineering reference: the gift aid claimer architecture
The same system with the service names filled in: what reads a declaration, what routes a donation, what builds the claim, and where the evidence sits.
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.
- 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
- Storage
- Database
- Security & identity
Region and account
- Region:
eu-west-2. London, because the donor addresses in a declaration are personal data belonging to UK residents and there is no reason to move them. Bedrock model availability is checked here rather than assumed; if the model needed is not present, the extraction step is the only part that would move. - 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 |
|---|---|---|---|
ga-declare | S3 put, declarations prefix | Reads a declaration into a donor and a dated coverage window | 60s / 1024MB |
ga-route | Nightly, after the donation load | Matches each donation to a donor and labels it with a scheme | 120s / 1024MB |
ga-claim | EventBridge, on period close | Freezes the routing, applies the caps, produces the schedule | 300s / 2048MB |
ga-evidence | Step after claim | Writes declarations, frozen donations and reasoning into one object | 300s / 2048MB |
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 |
|---|---|---|
ga-declare-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the declarations prefix; donors |
ga-route-role | s3:GetObject, dynamodb:Query, dynamodb:UpdateItem | The donations prefix; donors read; donations write |
ga-claim-role | dynamodb:Query, dynamodb:PutItem | donors and donations read; claims write |
ga-evidence-role | dynamodb:Query, s3:PutObject, ses:SendEmail | All three tables read; the evidence prefix write-once; 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: donors
PK charity_id#donor_id S
SK ’decl#’ + effective S one item per declaration, newest last
effective_from S the date coverage begins
covers_past BOOL whether the wording reaches back four years
cancelled_on S null until it is not
home_address S HMRC matches on this; no address, no claim
source S paper | web | verbal
confirmed_taxpayer BOOL the donor’s own statement
evidence_key S the document itself, not a flag
A donor is a LIST of declarations, not a boolean. Coverage on a given
date is a question asked of the list; a single gift_aid flag answers a
different question and is the most common cause of an over-claim.
Table: donations
PK charity_id#period S
SK donation_id S
donated_on S the date every decision turns on
amount N
method S bank | card | cash | contactless
donor_id S null for a bucket collection
route S gift_aid | small_donations | none
reason S set only when route is none
claim_id S set when a claim freezes this row
reason takes six values and they lead to different actions: no
declaration, declaration started later, donor cancelled, not a UK
taxpayer, over the small-donation limit, no donor identified.
Collapsing them into ’not claimable’ throws away the only actionable
output this system produces.
Table: claims
PK charity_id S
SK period#claim_id S claims are never overwritten
submitted_on S
gift_aid_donations N
gift_aid_value N
small_donations N
small_value N
binding_cap S allowance | matching | none
excluded_count N reported as prominently as the claim
excluded_value N
evidence_key S
binding_cap records WHICH limit bound rather than that one did. The
annual allowance and the matching rule have completely different fixes,
and a claim that just says ’capped’ cannot tell a charity which one it
is looking at.
Inbound and outbound
- Declarations arrive by upload or mailbox and both land in the same function. A photograph of a signed form and a web submission are the same document here.
- Donation exports are parsed, not read by a model. They are fixed-header CSV, they are the largest files in the system, and a model would be slower and less accurate than a parser.
- An uncertain donor match goes to a human. A donation claimed against somebody else’s declaration is the error that becomes a repayment, and asking costs one line on a list.
- The claim waits for the period close, and freezes its inputs before it sums anything. A declaration arriving the next morning belongs to the next run.
The model call
- One call per declaration. Nothing per donation, per claim or per donor lookup. The bill does not move when donations do.
- A mid-tier model. A declaration has five fields and the only ambiguous one is the date; a frontier model buys nothing here.
- Dates come back as strings in a stated format, never parsed by the model into a guess. A UK form writes 03/02 and the difference between February and March is a claim.
- Scope is extracted, not assumed. Whether the wording covers the previous four years is a field on the record, because it is the single largest recoverable amount in most charities.
- No model touches routing or arithmetic. Both have to be re-runnable and explainable years later, and both are a query and a multiplication.
Things worth knowing before you build it
- Store the declaration’s effective date, not a boolean. Coverage is a window with a beginning, and a donation is claimable or not according to where its own date falls.
- Read whether the wording covers the previous four years. Most standard forms do, most charities have no field for it, and re-running the routing over that period is usually the largest single win available.
- Keep the document, not a note that one exists. A ticked box builds a claim and does not defend one, and the difference is invisible until somebody asks.
- Record which cap bound the small-donations claim. The annual allowance and the matching rule against ordinary Gift Aid have different fixes.
- Count the retention from the end of the accounting period, not from the donation. A gift in the first month of a year is held nearly seven years.
- Report the excluded donations and their value beside the claim. It is the only number in the pack that a phone call can change.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts