Skip to content

Part 7 of 7 · Credit limit reviewer series ~7 min read

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

The credit limit reviewer drawn with AWS service namesThree boxes across the top outside the AWS account. Applications, arriving as a form plus the PDF they sent anyway. Order and ledger events covering accepted, despatched, invoiced and paid. And The order screen, asking for headroom. Inside the account, three groups. API Gateway for checks coming in and EventBridge for scheduled triggers. Four Lambda functions named intake, expose, check and review. And two DynamoDB tables named accounts and events. A note gives the region as us-east-1, one account, and states that exposure is a stored figure moved by events rather than a scan.AWS ACCOUNTApplicationsa form, plus the PDFthey sent anywayOrder and ledger eventsaccepted, despatched,invoiced, paidThe order screenasking for headroomAPI Gateway + EventBridgechecks in,triggers on a scheduleLambda x4intake, expose,check, reviewDynamoDB x2accounts, eventsingroundsoutus-east-1. One account. Exposure is a stored figure moved by events, never a scan.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 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 durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
cl-intakeAPI, on application submittedExtracts fields from the application PDF; writes the account and the opening limit60s / 1024 MB
cl-exposeEventBridge, on every order and ledger eventMoves the stored exposure figure and appends the event; recomputes headroom10s / 512 MB
cl-checkAPI, from the order screenSingle-item read; returns headroom, reason and the available alternatives5s / 512 MB
cl-reviewEventBridge, dailyEvaluates the three triggers; queues review tasks with the evidence attached120s / 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

RoleAllowedOn
cl-intake-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the applications prefix; accounts
cl-expose-roledynamodb:UpdateItem, dynamodb:PutItemAccounts; events
cl-check-roledynamodb:GetItemAccounts only, read
cl-review-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailEvents; 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