Skip to content

Part 7 of 7 · Capacity forecaster series ~7 min read

Engineering reference: the capacity forecaster 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 immutable forecast record, and how capacity is derived.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 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 capacity forecaster drawn with AWS service namesThree boxes across the top outside the AWS account. Output records, showing what was delivered. The order book and quotes. And A weekly summary carrying dated warnings. Inside the account, three groups. EventBridge running weekly to compute capacity, then the forecast, then scoring. Three Lambda functions named capacity, forecast and score. And two DynamoDB tables named forecasts and actuals. A note gives the region as us-east-1, one account, and states that forecasts are immutable once written and scoring compares them to actuals.AWS ACCOUNTOutput recordswhat was deliveredThe order bookand quotesA weekly summarydated warningsEventBridge weeklycapacity, thenforecast, then scoreLambda x3capacity, forecast, scoreDynamoDB x2forecasts, actualsingroundsoutus-east-1. One account. Forecasts are immutable once written; scoring compares to actuals.
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
  • Management
  • Analytics
  • 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
cf-capacityEventBridge, weeklyDerives measured capacity per stage from delivered output over the trailing year120s / 1024 MB
cf-forecastEventBridge, weeklyProjects demand with seasonality and the late-arrival uplift; writes an immutable forecast for every horizon300s / 1024 MB
cf-scoreEventBridge, weeklyCompares forecasts whose target week has arrived against actuals; updates bias by horizon120s / 1024 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
cf-capacity-roledynamodb:Query, dynamodb:PutItemRead-only on output records; writes actuals
cf-forecast-roledynamodb:Query, dynamodb:PutItem, ses:SendEmailBoth tables, put only on forecasts; one verified identity
cf-score-roledynamodb:Query, dynamodb:UpdateItemBoth tables

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: forecasts

PK   made_on           S   2026-08-23 — the date it was produced
SK   stage#target_week S   fitting#2027-W04
     horizon_weeks     N   22
     demand_likely     N   210
     demand_busy       N   238
     capacity          N   182 — measured, at the time
     movable_hours     N   18  — work that could shift week
     uplift_used       N   0.27 — the late-arrival factor in force
     actions_taken     L   [{date, what, by}] — appended later

No update path except appending to `actions_taken`. A revised forecast
is a new item with a later `made_on`, never an edit to this one.

Table: actuals

PK   stage             S   fitting
SK   week              S   2027-W04
     demand_actual     N   what was genuinely required
     delivered         N   what was produced
     capacity_actual   N   what was available that week
     shortfall         N   demand minus capacity, if positive
     warned            BOOL was there a forecast that saw this?

`warned` is how the ’shortfalls that occurred with no warning’ line in
the annual review gets computed, which is the most useful line on it.

Inbound and outbound

  • Output records are read, never written. Capacity comes from whatever the business already records as delivered work.
  • The late-arrival uplift is measured, not configured by opinion: compare each past week’s order book at four weeks out against what was actually delivered.
  • Seasonality comes from three years of weekly actuals where they exist, and the forecast says so where they do not.
  • Every horizon is written every week. Twenty-six items per stage per week is trivial storage and is what makes scoring by horizon possible.

The model call

  • There is no model in this system. Seasonality is an index from historical weeks and the uplift is a measured ratio.
  • The tempting use is a time-series model. At weekly granularity with three years of history, a seasonal index plus a measured uplift is competitive and explainable.
  • Explainability is the point. Somebody is going to be asked to hire two people on the strength of this, and “the model says so” is not sufficient.
  • A defensible use is classifying free-text order descriptions into stages where the order system does not do it.
  • The cost page assumes none, which is why the bill is fixed.

Things worth knowing before you build it

  • Write forecasts immutably at every horizon, every week. Storing only the latest one makes scoring impossible and the scoring is where the method improves.
  • Measure capacity from delivered output, never from headcount times hours. The paper figure has never been achieved and forecasting against it hides every shortfall.
  • Score demand accuracy, not shortfall accuracy. A shortfall that was averted by acting on the forecast will otherwise be counted as a forecasting error.
  • Report weeks of notice alongside every shortfall. Without it nobody can tell whether the warning is a decision or an announcement.
  • Re-check which stage is the constraint at least quarterly. A bottleneck analysis from two years ago is often describing a stage that is no longer binding.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts