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
- 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
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 |
|---|---|---|---|
cf-capacity | EventBridge, weekly | Derives measured capacity per stage from delivered output over the trailing year | 120s / 1024 MB |
cf-forecast | EventBridge, weekly | Projects demand with seasonality and the late-arrival uplift; writes an immutable forecast for every horizon | 300s / 1024 MB |
cf-score | EventBridge, weekly | Compares forecasts whose target week has arrived against actuals; updates bias by horizon | 120s / 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
| Role | Allowed | On |
|---|---|---|
cf-capacity-role | dynamodb:Query, dynamodb:PutItem | Read-only on output records; writes actuals |
cf-forecast-role | dynamodb:Query, dynamodb:PutItem, ses:SendEmail | Both tables, put only on forecasts; one verified identity |
cf-score-role | dynamodb:Query, dynamodb:UpdateItem | Both 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