Engineering reference: the credit limit reviewer 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 event-sourced exposure figure, 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
- Database
- Networking
- 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 |
|---|---|---|---|
cl-intake | API, on application submitted | Extracts fields from the application PDF; writes the account and the opening limit | 60s / 1024 MB |
cl-expose | EventBridge, on every order and ledger event | Moves the stored exposure figure and appends the event; recomputes headroom | 10s / 512 MB |
cl-check | API, from the order screen | Single-item read; returns headroom, reason and the available alternatives | 5s / 512 MB |
cl-review | EventBridge, daily | Evaluates the three triggers; queues review tasks with the evidence attached | 120s / 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 |
|---|---|---|
cl-intake-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the applications prefix; accounts |
cl-expose-role | dynamodb:UpdateItem, dynamodb:PutItem | Accounts; events |
cl-check-role | dynamodb:GetItem | Accounts only, read |
cl-review-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Events; accounts; 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: accounts
PK account_id S
SK ’#limit’ S one item per account
legal_name S registered name, not the trading name
company_no S
terms_days N
limit_amount N
limit_reason S free text, required, shown at the next review
limit_set_at S
limit_set_by S
exposure N the stored figure, moved by cl-expose
headroom N derived; written alongside so the check is one read
dbt_90 N average days beyond terms, trailing 90 days
review_state S none | queued | in_progress
The check path reads exactly this item and nothing else. That is
deliberate: an eight-second check is a check that gets bypassed.
Table: events
PK account_id S
SK occurred_at#id S
kind S accepted | despatched | invoiced | paid |
credited | disputed | override
amount N signed; the exposure delta this event applied
ref S order or invoice number
actor S set on override, and only on override
reason S picklist value on override; free text on dispute
ttl N 24 months; the trend needs a year, not a decade
Exposure is replayable from this table, which is how you prove the
stored figure is right rather than hoping it is.
Inbound and outbound
- Order events are the input, not invoices. Acceptance moves exposure; invoicing only changes which bucket it sits in.
- Unallocated payments reduce exposure immediately. Waiting for the cash posting blocks customers who have already paid, which is the worst error this system can make.
- Disputes set a flag, not a credit. Chasing stops; the exposure figure does not move until a credit note exists.
- The check endpoint is read-only and has no write permissions at all, so the busiest path in the system cannot corrupt anything.
The model call
- One call, at application time only. Extracting registered name, company number, requested terms and expected monthly spend from whatever the customer sent.
- A small, fast model. This is field extraction from a two-page document, not analysis, and a larger model produces identical fields at eight times the price.
- A JSON schema it must fill or leave null. A guessed company number is worse than an empty one, because somebody will act on it.
- It does not set the limit. The limit comes from a stated policy applied to extracted fields, so it can be explained to a customer who asks.
- It never reads the ledger. Payment behaviour is arithmetic, and arithmetic does not need a language model.
Things worth knowing before you build it
- Count exposure from order acceptance. The gap between acceptance and invoicing is about a trading week, and that week is when failures land.
- Store headroom next to the limit. Deriving it on demand is what makes the check slow enough to be bypassed.
- Never send a limit reduction automatically. Queue it for somebody who can pick up a phone, because the email version ends relationships.
- Record every override with a name and a reason from a picklist. Free text produces ’ok per Dave’ four hundred times and no usable data.
- Give the trigger list three entries and defend it. Every added trigger costs you a percentage of the reviews that actually get worked.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts