Engineering reference: the invoice dispute triager 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 inbound mail path, and the single model call.
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
- App integration
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 |
|---|---|---|---|
id-recognise | S3 ObjectCreated | Header filters, remittance detection, invoice match, pauses the chaser | 10s / 512 MB |
id-classify | SQS dispute queue | One Bedrock call into a reason, a line and a confidence | 20s / 1024 MB |
id-gather | SQS classified queue | Fetches the evidence for that reason and writes the one-liner | 30s / 1024 MB |
id-record | Function URL | Handles the signed decision links; resumes chasing on the right schedule | 10s / 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 |
|---|---|---|
id-recognise-role | s3:GetObject, dynamodb:UpdateItem | The mail prefix; the invoices table disputed flag only |
id-classify-role | bedrock:InvokeModel, sqs:SendMessage | One model arn; the classified queue |
id-gather-role | s3:GetObject, ses:SendRawEmail | The documents prefix; one verified identity |
id-record-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue | Disputes and invoices; the signing key 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: disputes
PK dispute_id S dsp_2026_07_14_9c2b
invoice S 4412
customer S acme-ltd
state S open | gathered | decided
reason S quantity | price | duplicate | not_ordered |
wrong_entity | not_invoice | unknown
reason_actual S set when a person decides
confidence N 0.91
line S which invoice line, where determinable
evidence L [{kind, s3_key, one_liner}]
evidence_gap S none | no_signed_pod | no_quote_on_file
outcome S upheld | not_upheld | partial | withdrawn
opened / closed S ISO timestamps
ttl N epoch, +7 years
GSI customer-index PK customer, SK opened — the per-customer view
Table: invoices
PK invoice S 4412
customer S acme-ltd
total N 3240.00
lines L [{description, qty, unit_price}]
disputed BOOL true — what the chaser reads
dispute_id S dsp_2026_07_14_9c2b
chase_from S 2026-07-21 — the grace period after a decision
This table is a projection of your real ledger, not a replacement for it.
The only field this system writes is `disputed` and `chase_from`.
Inbound and outbound
- An SES receipt rule set on the accounts address writes the whole message to S3 with attachments. The S3 event fires
id-recognisedirectly. - Header filters run before anything else.
Auto-Submitted,X-Autoreplyand a bulk precedence are all discarded without a model call. - Threading uses
In-Reply-ToandReferencesto find the invoice, which is more reliable than parsing a number out of a body. - Decision links are signed, scoped to one dispute, single-use, and expire after thirty days.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock. The task is classifying a short customer message into one of six labels. - Called once per recognised dispute, after every free structural filter has run, and never on an auto-reply or a remittance.
- Output is a JSON schema with a reason, an optional second reason, an optional invoice line, and a confidence. Every field is nullable and a null reason is the hand-over-raw path.
- Grounded with the invoice lines, so the model can identify which line is being disputed rather than only what kind of complaint it is.
- Nothing about the evidence touches a model. Comparing a claimed quantity with a signed delivery note is a lookup and a comparison, and both should be code.
Things worth knowing before you build it
- Pause the chaser before classifying, not after. It is the only irreversible step in the sequence and it has to happen within seconds.
- Match invoice numbers within the customer, not globally. Customer-scoped matching removes almost every false positive at no cost.
- Set the confidence floor high. A wrong class attaches the wrong paperwork and costs two round trips; an honest unknown costs one.
- Keep `outcome` and `reason` as separate fields. Conflating them makes the monthly report unable to answer either question it exists to answer.
- Do not resume chasing automatically on close. A reminder fired at somebody you have just told they were wrong is a worse outcome than a week of delay.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts