Engineering reference: the purchase order approver 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 and what each is allowed to touch, the two tables and their keys, the inbound mail path, and the specific model. Nothing here is a secret and nothing here is load-bearing on a diagram.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- Six Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- Two DynamoDB tables: requests (keyed by request id) and ledger (keyed by budget line and period).
- Inbound mail through an SES receipt rule set writing to S3, which is what triggers intake.
- One Bedrock model, called once per request, with a JSON schema it must fill or leave null.
The system, by service name
- Compute
- Storage
- Database
- App integration
- Analytics
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules are only available in a subset of regions and this is the one with 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 a durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
po-intake | S3 ObjectCreated + API Gateway + Slack | Normalises all three lanes into one queue message | 10s / 512 MB |
po-read | SQS request queue | Duplicate test, then one Bedrock call into the five fields | 30s / 1024 MB |
po-check | SQS checked queue | The four budget checks against the sheet and the ledger | 10s / 512 MB |
po-route | SQS routed queue | Auto-approve, ask a person, or return to the requester | 10s / 512 MB |
po-decide | Function URL | Handles the signed approve/decline links; reserves the money | 10s / 512 MB |
po-send | SQS order queue | Builds the PO PDF and sends it through SES | 30s / 1024 MB |
Six functions rather than one is a deliberate choice, and the reason is not modularity. It is that po-read is the only one that needs Bedrock permissions and po-decide is the only one reachable from the public internet. Splitting them means the internet-facing function cannot call a model and the model-calling function cannot be reached from the internet, and neither of those is true if it is all one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
po-intake-role | s3:GetObject, sqs:SendMessage | The mail prefix; the request queue only |
po-read-role | bedrock:InvokeModel, dynamodb:PutItem, sqs:*Message | One model arn; requests table; two queues |
po-check-role | dynamodb:GetItem, secretsmanager:GetSecretValue | Ledger table; the sheet credential only |
po-route-role | ses:SendEmail, sqs:SendMessage | One verified identity; the order queue |
po-decide-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue | Requests and ledger; the signing key only |
po-send-role | ses:SendRawEmail, s3:GetObject | One identity; the attachment prefix |
No role has a Resource: “*” on anything that writes. The two GetSecretValue grants name a single secret arn each, which is why there are two secrets rather than one JSON blob with both values in it: a single secret would mean the checker could read the link-signing key, and it has no business knowing it.
DynamoDB schemas
Table: requests
PK request_id S req_2026_07_08_4f1a
status S pending | approved | declined | returned
fingerprint S sha256(requester|vendor|amount|day)
requester S dana@example.com
item S Replacement bench grinder
line S workshop-consumables
amount N 640.00
vendor S Medline Industries
raw_key S s3://po-mail/2026/07/08/4f1a.eml
decided_by S owner@example.com | auto
decided_at S 2026-07-08T09:14:02Z
ttl N epoch, +7 years
GSI fingerprint-index PK fingerprint — the duplicate test
GSI status-index PK status, SK created_at — the escalator’s sweep
Table: ledger
PK line S workshop-consumables
SK period S 2026-Q3
limit N 4000.00
committed N 2850.00
invoiced N 1900.00
approver S owner@example.com
second_approver S ops@example.com
auto_below N 250.00
updated_at S 2026-07-08T09:14:02Z
The reservation write:
UpdateExpression: SET committed = committed + :amt
ConditionExpression: committed = :seen AND committed + :amt <= #limit
That condition expression is the entire concurrency story. Two approvals racing against the same line cannot both satisfy committed = :seen, so exactly one wins and the other is rejected with ConditionalCheckFailedException, which the caller turns into the “no room now” path from Part 5 rather than into a retry.
Inbound mail
- An SES receipt rule set on the requests domain, with one rule: recipient
buy@, actionsS3thenStop. - The S3 action writes to
po-mail/with a KMS key, which is what firespo-intake. There is no SNS hop; the object creation event is enough. - Spam and virus verdicts are on the SES headers written into the object.
po-intakereads them and drops anything failing either, before any parsing. - SPF and DKIM on the outbound identity, plus a DMARC record. Purchase orders that fail authentication get filed as junk by the vendor, which looks exactly like the system not working.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock. The task is extraction from a short email, which is what a small fast model is for. - Called once per request, after the duplicate test, never in a loop and never for a retry that has already read the same message.
- Output is a JSON schema with five fields, every one nullable. Null is a first-class answer and is what produces the “which budget line?” reply rather than a guess.
- Grounded with the budget line names and the vendor list in the prompt, read from the sheet through a five-minute cache. The model picks from a list; it does not invent a line.
- No tools, no chaining. One call in, one JSON out. Everything consequential after that point is code.
Things worth knowing before you build it
- SES inbound receipt rules exist in only a few regions. Pick the region for that constraint first, then check Bedrock model availability in it.
- A new SES identity is in the sandbox. Purchase orders to a real vendor need production access, which is a support request and takes a day or two.
- Function URLs are public by default.
po-decidemust do its own HMAC check on the first line of the handler, before any parsing and before any database read. - DynamoDB on-demand is the right mode here and provisioned is not. The traffic is bursty, tiny, and completely unpredictable.
- Set the ledger period key to whatever your budget period actually is. Quarters are the common case; a business running annual budgets should not be pretending otherwise in a sort key.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts