Skip to content

Part 7 of 7 · Purchase order approver series ~7 min read

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

The purchase order approver drawn with AWS service namesThree boxes across the top outside the AWS account. SES inbound, with an MX record on the requests domain. The Google Sheets API, serving the budget and vendor tabs. And SES outbound, which carries the approval asks, the purchase orders and the receipts. Inside the account, three groups. S3 and SQS, holding raw mail, attachments and the single request queue. Six Lambda functions named intake, read, check, route, commit and send. And two DynamoDB tables, requests and ledger. Arrows show the SES receipt rule writing into S3, the sheet being read read-only with a five-minute cache, and SendRawEmail going back out. A note gives the region as us-east-1, one account, with Secrets Manager holding the sheet credential and the link-signing key.AWS ACCOUNTSES inboundMX on the requests domainSheets APIbudget + vendor tabsSES outboundasks, POs, receiptsS3 + SQSraw mail, attachments,one request queueLambda x6intake, read, check,route, commit, sendDynamoDB x2requests, ledgerreceipt rule -> S3read-only, cached5mSendRawEmailus-east-1. One account. Secrets Manager holds the sheet credential and the link-signing key.
Fig 1. The same three-part shape as Part 1, with the service names filled in. Inbound mail lands in S3 through an SES receipt rule, six Lambda functions do the work, and two DynamoDB tables hold every piece of state.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
po-intakeS3 ObjectCreated + API Gateway + SlackNormalises all three lanes into one queue message10s / 512 MB
po-readSQS request queueDuplicate test, then one Bedrock call into the five fields30s / 1024 MB
po-checkSQS checked queueThe four budget checks against the sheet and the ledger10s / 512 MB
po-routeSQS routed queueAuto-approve, ask a person, or return to the requester10s / 512 MB
po-decideFunction URLHandles the signed approve/decline links; reserves the money10s / 512 MB
po-sendSQS order queueBuilds the PO PDF and sends it through SES30s / 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

RoleAllowedOn
po-intake-roles3:GetObject, sqs:SendMessageThe mail prefix; the request queue only
po-read-rolebedrock:InvokeModel, dynamodb:PutItem, sqs:*MessageOne model arn; requests table; two queues
po-check-roledynamodb:GetItem, secretsmanager:GetSecretValueLedger table; the sheet credential only
po-route-roleses:SendEmail, sqs:SendMessageOne verified identity; the order queue
po-decide-roledynamodb:UpdateItem, secretsmanager:GetSecretValueRequests and ledger; the signing key only
po-send-roleses:SendRawEmail, s3:GetObjectOne 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@, actions S3 then Stop.
  • The S3 action writes to po-mail/ with a KMS key, which is what fires po-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-intake reads 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:0 on 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-decide must 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