Engineering reference: the restricted fund tracker architecture
The same system with the service names filled in: what reads an agreement or an invoice, what tests a cost against a grant’s rules, what shares a salary, and what notices a fund going below zero.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 5 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
- People
Region and account
- Region:
eu-west-2. London, because the charity, its funders and its payroll data are all in the UK and nothing here needs to leave it. Documents arrive by upload into S3 rather than by email, so SES inbound regions do not constrain the choice; SES is used only to send. Bedrock model availability is checked in this region rather than assumed, and the read function is the only step that would have to 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 |
|---|---|---|---|
rft-read | S3 put, documents prefix | Reads an agreement, variation or invoice into draft rules or a proposed posting | 120s / 1024MB |
rft-test | SQS, after each proposal | Tests a proposed posting against the rules version in force on its date | 30s / 512MB |
rft-share | Monthly, after the payroll export | Parses payroll and timesheets and allocates shared costs by basis | 300s / 2048MB |
rft-balance | EventBridge, weekly | Recomputes fund positions and raises deficits and underspend deadlines | 300s / 1024MB |
rft-report | EventBridge, on a report due date | Freezes the period, builds the funder report and writes its trace | 900s / 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 |
|---|---|---|
rft-read-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem, sqs:SendMessage | One model id; the documents prefix; draft grant rules and proposed postings only |
rft-test-role | dynamodb:Query, dynamodb:UpdateItem | grants read; postings update, conditional on state = proposed. No Bedrock |
rft-share-role | s3:GetObject, dynamodb:Query, dynamodb:PutItem | The payroll and timesheets prefixes; grants read; postings write |
rft-balance-role | dynamodb:Query, dynamodb:PutItem, ses:SendEmail | grants and postings read; draft periods rows; one verified identity |
rft-report-role | dynamodb:Query, dynamodb:UpdateItem, s3:PutObject, ses:SendEmail | All three tables read; periods state; the reports 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: grants
PK org_id#grant_id S
SK effective_from#version S a variation adds a version; none is edited
purpose S quoted from the agreement
period M {start, end, change_request_by}
lines L [{line, amount, categories, move_limit}]
overheads M {allowed, basis, cap} or not_stated
reports L [{due, contents}]
underspend S repay | retain | by_agreement | not_stated
sources M field -> {doc_key, clause, page, quote}
approved_by S empty until a person signs the rules off
not_stated is a value, not a missing field. A grant whose agreement says
nothing about overheads has to route every overhead charge to a person,
and that only works if silence is stored as silence rather than as the
default somebody assumed was usual.
Table: postings
PK org_id#fund_id S a grant id, or unrestricted
SK incurred_on#posting_id S
amount N
line S the budget line within the grant
category S from the grant’s eligible list
project S kept alongside, never instead
rule_version S the grants SK this posting passed
reason S proposed by the model, approved by a person
evidence_key S the invoice, payslip or timesheet
allocation M {pool, basis, numerator, denominator,
share, charged}
state S proposed | charged | held | moved
moved_to S the posting that replaced this one
rule_version is what lets a posting explain itself after a variation.
A staff cost from month three was tested against the budget as it stood
in month three; re-testing it against today’s rules gives a different
answer, and without the version there is no record of the original.
Table: periods
PK org_id#fund_id#period S e.g. youth#FY or energy#Q3
SK version S version 1 is never edited
income N
charged N the sum of charged postings
transfers L [{amount, from_fund, minute_ref}]
expected_income M {source, amount, basis} when in deficit
balance N
underspend_action S repaid | retained | change_agreed | open
posting_ids L the postings this version used
report_key S the report as sent
state S draft | frozen | reported | superseded
minute_ref is the field that separates a deficit from a decision. A
negative balance with neither a transfer carrying a minute_ref nor a
recorded expected_income is the silent deficit from part 5, and the
close refuses to freeze a period in that state.
Inbound and outbound
- Documents arrive by upload, not by email. Agreements, variations and invoices land under one S3 prefix, synced from the finance mailbox or a shared drive. Nothing here needs SES inbound.
- Payroll and timesheets are parsed, not read. Both are fixed-layout exports. A model over them would cost money every month and occasionally misread a figure that ends up in a funder report.
- Nothing is posted to the ledger automatically. The system produces journals and a summary for the finance lead, who posts them. The ledger stays the record the accounts are prepared from.
- Alerts go to named people. A deficit goes to the finance lead and the treasurer; an underspend deadline also goes to the grant’s owner, because only they can ask the funder for a change.
The model call
- One call per document. Invoices as they arrive, agreements and variations when they are signed. Nothing per posting, per fund, per allocation or per period.
- A small, fast model. Supplier, date, amount, description and a proposed fund is extraction with a short reason attached, not reasoning.
- The model proposes; code decides eligibility. Period, category, line headroom and evidence are tested against approved rules in
rft-test. A confident model approving an excluded cost is the failure this split exists to prevent. - Every extracted term carries a quotation. The agreement read returns the clause text and page for each field it fills, and the approval screen shows the two side by side.
- Absent means null. An agreement silent on overheads or underspend comes back as not_stated, never as the sector’s usual answer. A plausible default is indistinguishable from a real term a year later.
Things worth knowing before you build it
- Code to the fund, not only the project. The same salary can be eligible on one grant and excluded on another, and a project code cannot hold that distinction.
- Test the date a cost was incurred, not the invoice date or the payment date. Paying for next year’s activity early to use up this year’s grant is one of the first things a funder checks.
- Store the allocation basis and its numbers, not the percentage. 271 of 1,586 hours can be checked; 17 per cent cannot.
- Apply the overhead cap after calculating the share. The difference is what the charity funds itself, and it belongs in the next bid.
- Check fund balances weekly. A deficit caught in month four is a decision; one found at year end is a disclosure.
- Version the rules on every variation. A report sent before a variation must still reconcile to the budget it was built against.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts