Engineering reference: the waste collection verifier 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 expected-versus-actual join, and the one model call per invoice.
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.
- 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
- Front-end & mobile
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 |
|---|---|---|---|
wc-terms | S3 upload of a contract or variation | One model call; writes the schedule, the rate card, the windows and the renewal date | 60s / 1024 MB |
wc-check | API, from the morning check | Marks each expected collection done, missed or partial; stores the resized photograph | 10s / 512 MB |
wc-invoice | S3 upload of an invoice | One model call to line items; matches every line to a rate; opens queries for the rest | 120s / 2048 MB |
wc-pursue | EventBridge, daily | Fires reporting and challenge windows, chases credits, and warns on the renewal notice date | 60s / 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 |
|---|---|---|
wc-terms-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the contracts prefix; sites |
wc-check-role | dynamodb:UpdateItem, s3:PutObject | Events; the evidence prefix |
wc-invoice-role | bedrock:InvokeModel, s3:GetObject, dynamodb:Query, dynamodb:PutItem | One model id; the invoices prefix; both tables |
wc-pursue-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Events; 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: sites
PK site_id S
SK ’#terms’ S one item per site
containers L [{stream, size, count, days}]
rates M standing, lift, per_tonne, named surcharges
escalation M {window, cap, index}
report_window_h N hours to report a miss — usually 24 or 48
challenge_days N days to challenge an invoice line
term_end S
notice_months N the field that decides whether you have a choice
notice_by S term_end minus notice_months, precomputed
source_key S the contract PDF
notice_by is stored rather than derived, because it is the one date
that has to fire even if nobody ever opens this record again.
Table: events
PK site_id#period S
SK due_at#stream S one item per expected collection
expected BOOL generated ahead from the schedule
state S done | missed | partial | no_access
fill S low | half | full | overflow
photo_key S only on exceptions, and on a sample
reported_at S inside report_window_h, or the claim is dead
report_ref S the contractor’s reference
charged N from the invoice line that matched
credit_expected N
credit_received N
query_state S none | raised | answered | credited | rejected
Expected collections are written ahead of time. A missing actual is
then a row that stayed in expected state, not an absence of data.
Inbound and outbound
- Expected collections are generated forward from the schedule, monthly. Detecting a miss becomes a query for rows nobody updated, which is far more reliable than inferring absence.
- The morning check is one request and is silent when everything is normal. Notifications only ever come from exceptions.
- Photographs are kept on exceptions and on a small random sample of normal days, which is what makes a challenge credible.
- Invoices arrive by upload or by mailbox, and both land in the same function. Nothing here depends on a contractor portal or an integration that could be withdrawn.
The model call
- Two calls, both per document. One per contract or variation letter, one per invoice. Nothing per collection.
- A more capable model for invoices, deliberately. Multi-page scanned itemised documents are where cheap extraction quietly produces wrong quantities.
- A JSON schema, with nulls allowed. A missing notice period becomes a question to a human, because guessing that field can cost three years.
- Reconciliation is arithmetic, not a model judgement. Every discrepancy has to be stateable to a contractor in one sentence with a line reference.
- No image model on the evidence photographs. They are for a human at the point of dispute, and a fill level from a tap is more reliable than one from a picture of a bin.
Things worth knowing before you build it
- Generate expected collections ahead of time. Detecting a miss as a row that never got updated is reliable; inferring it from the absence of a record is not.
- Store the notice date, not just the term end. The decision point is six to nine months before the date everybody has in their head, and it is the most expensive field in the contract.
- Report misses within the contractual window, which is usually 24 or 48 hours. Almost every failed credit claim failed on timing rather than on merit.
- Cut collections against your busiest measured month, never the average. A frequency reduction reversed in November is reinstated at a worse rate.
- Keep the exception photographs and expire the rest. The evidence that matters is small, and keeping everything at full resolution is the only way to make this system cost real money.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts