Autumn already protects itself against overload reactively: bounded-concurrency
load shedding (ADR-0009)
returns 503 once too many requests are in flight. But the ceiling it enforces
has always been a hand-tuned guess in autumn.toml, and nothing told you what a
given binary could actually sustain before you deployed it.
A capacity contract replaces that guess with a measurement. It is a
committed, versioned artifact — capacity.lock — that says:
On this host class, this build sustains N req/s at P99 ≤ M ms; past that point it admission-controls rather than degrades.
Four things use it:
| Step | Command / setting | What it does |
|---|---|---|
| Calibrate | autumn calibrate | Measures the envelope, writes capacity.lock |
| Commit | git add capacity.lock | The contract travels with the build, like Cargo.lock |
| Gate | autumn calibrate --check | Fails CI with a diff when a rebuild leaves the envelope |
| Enforce | [server] capacity_contract | The runtime admits against the proven envelope |
1. Calibrate
autumn calibrate
The run:
- Builds your app in release mode — a debug-profile number is not something to size infrastructure from.
- Reads the app's route graph back through the same
AUTUMN_DUMP_ROUTESpipelineautumn routesuses, recording each route's statically derived resource shape (see Per-route resource shape). - Boots the binary on a reserved port with admission control switched off, so the run measures the app rather than a ceiling it was already carrying.
- Warms up, then walks a seeded concurrency ladder (
1,2,4,8,16,32,64by default), holding each rung for two seconds and measuring it three times, keeping the median. - Records the saturation knee — the last rung where more concurrency still
bought materially more throughput — and writes
capacity.lock.
saturation_concurrency is the knee as measured. admission_limit is
deliberately not the same number: it carries a 2x headroom factor, floored
at the host's logical CPU count. Both adjustments exist because the concurrency
a loopback driver offers is not the concurrency the runtime counts. A load-shed
slot is held from the moment the layer sees a request until the handler's future
resolves — which in production also spans reading the request body off a real
network — so by Little's law the same throughput needs a strictly larger
in-flight count over a WAN than over 127.0.0.1. Enforcing the raw knee would
shed traffic the binary can actually serve. The floor covers the other
direction: a ladder whose second rung fails to gain (a CPU-quota'd container, a
noisy runner, one route that serialises on a lock) puts the knee at concurrency
1, and a contract licensing a single in-flight request would brown out the
deploy. Headroom errs toward admitting; the measured envelope is still recorded
unscaled.
# capacity.lock
version = 1
[provenance]
autumn_version = "0.10.0"
calibrated_at = "2026-09-01T12:04:11Z"
git_commit = "9f2c1ab"
git_dirty = false
route_graph_digest = "sha256:4c1d…"
[host]
os = "linux"
arch = "x86_64"
logical_cpus = 8
total_memory_mb = 16384
[calibration]
profile = "prod"
seed = 1733
concurrency = [1, 2, 4, 8, 16, 32, 64]
rung_ms = 2000
runs = 3
warmup_ms = 1000
[envelope]
sustained_rps = 4210.5
p99_latency_ms = 18.42
saturation_concurrency = 64
admission_limit = 128
[[routes]]
method = "GET"
path = "/posts"
handler = "posts::index"
shape = "db-bound"
pools = ["db"]
Useful flags: --concurrency 1,4,16,64,256 to reshape the ladder, --runs and
--rung-ms to trade run time for stability, --seed to change the request
profile, -p / --bin to pick a target in a workspace, --features /
--all-features / --no-default-features to build the binary you actually
deploy, --profile to calibrate under a profile other than prod, and
--target /path to name the routes to drive when discovery is too
conservative.
Profile, features, and the binary you deploy
A contract is only worth as much as the resemblance between the calibrated binary and the deployed one, so both the Cargo feature selection and the autumn profile are recorded and replayed:
- The binary is built in release mode with whatever
--featuresyou pass. Calibrating a default-feature build and then enforcing its limit on a production binary that enables more is a contract about a program nobody runs. - The app boots under
prodby default — the profileautumn deploypins — not the ambient one. Profile overlays change middleware, backends and pool sizing, so adevenvelope would govern a materially different program. - Because
prodfails fast on missing prerequisites, the run supplies a per-run random signing secret and loopback-only trusted hosts, scoped to that one throwaway process. Neither is performance-relevant, and anything already set in your environment is left alone. - The child starts in the directory of the package that owns the binary.
Without
-p, that is the only workspace member that has a binary. The tool looks for it from the working directory. The child reads that package'sautumn.toml, not the workspace root's. - The ladder speaks plain HTTP over a reserved TCP port.
server.unix_socketis cleared for the calibration child, since the driver cannot dial one. In-process TLS is not switched off — doing so via the environment would switch TLS on for every other app, because the override materializes[server.tls]from the presence of the variable — so a profile that terminates TLS itself cannot be calibrated directly; use--profileto pick one that does not. The cost this omits (TLS handshake and record framing) is per-connection, which a keep-alive ladder barely pays.
--target is recorded in the contract too, so a run against explicitly named
routes is replayed rather than rediscovered.
The workload is recorded in the contract, and --check replays it rather
than its own defaults:
[calibration]
profile = "prod"
seed = 1733
concurrency = [1, 2, 4, 8, 16, 32, 64]
rung_ms = 2000
runs = 3
warmup_ms = 1000
That matters more than it looks. An envelope only means something next to the
workload that produced it: gating a contract measured with --concurrency 1,8,64 --rung-ms 5000 against a rebuild measured with the default ladder
compares two different experiments, and the verdict would be about the flags
rather than the build. Pass a workload flag explicitly to --check and it is
honoured, with a warning saying exactly that.
What gets driven
A calibration run only drives load against routes where doing so measures the application:
GETonly, so a run never mutates application state.- No path parameters, because a fabricated id measures the 404 path.
- Not
gated, because an unauthenticated run measures the auth rejection. - Application routes only — framework probes are exempt from load shedding anyway and would flatter the envelope.
If your app exposes none of these, autumn calibrate says so rather than
inventing a number.
2. Commit
capacity.lock belongs in version control next to autumn.toml. It is
canonicalized on write — routes sorted, pool lists sorted and deduplicated — so
re-calibrating an unchanged route graph produces a byte-identical route section
and the diff shows only the numbers that actually moved.
3. Gate in CI
autumn calibrate --check
--check re-measures, compares against the committed contract, and exits
non-zero with a human-readable diff:
🍂 autumn calibrate --check
host linux/x86_64 8 vCPU (committed: linux/x86_64 8 vCPU)
sustained req/s 4210.5 → 2940.1 (-30.2%, tolerance -15.0%)
P99 latency (ms) 18.42 → 19.03 (+3.3%, tolerance +25.0%)
✗ this build no longer meets the committed capacity.lock:
- sustained throughput regressed 30.2% (4210.5 → 2940.1 req/s), beyond the 15.0% tolerance
Tolerances default to 20% for throughput and 25% for P99, adjustable
with --tolerance-rps / --tolerance-p99. They are sized to sit between
observed run-to-run noise on a quiet host and a regression worth paging about.
The P99 band also carries 1 ms of absolute slack on top of the proportional
one: an app whose handlers return a &'static str has a sub-millisecond
loopback P99, where 25% is less headroom than a single context switch on a
shared runner consumes, and a gate that fails no-op rebuilds of the very apps
it is most likely pointed at is worse than no gate.
Exit codes distinguish the two ways --check can be red: 1 means this build
regressed, 2 means the gate could not judge it (a different host class, or a
committed contract that records no usable envelope). Only the first is a reason
to go optimise something.
The gate is deliberately narrow. It compares two numbers, and only when the rebuild ran on the same host class as the contract. It never compares timestamps, git provenance, or the contract digest, because a no-op rebuild changes all three — and a capacity gate that cries wolf gets switched off, which is strictly worse than not having one.
Route-graph changes are reported alongside the numbers but never fail the gate:
route graph:
• route added: POST /posts (db-bound [db])
• route shape changed: GET /search compute-bound → db-bound [db]
They are a reason to read the diff, not to block a build that still meets its envelope.
Running it on the right machine
A contract is about a host class, not about your code alone. --check on a
different class exits non-zero with a distinct diagnosis:
✗ host class differs from the committed capacity.lock, so the two envelopes
are not comparable.
Give the capacity gate a dedicated CI lane on a stable runner class. Bolting it onto a shared, noisy-neighbour lane produces exactly the false positives the tolerances are designed to avoid.
How much noise to expect
This is worth being concrete about, because it decides whether the gate is worth having. Measured on a shared 4-vCPU container, repeatedly calibrating an identical build:
| Method | Spread across no-op rebuilds | No-op rebuilds passing |
|---|---|---|
| One measurement per rung | up to 20% | 1 of 3 |
| Median of three (the default) | up to 8% | 5 of 5 |
A single sample per rung makes the verdict hostage to whatever else the machine
was doing during that one window — wider than the throughput tolerance itself,
so no-op rebuilds fail. --runs (default 3) is the mitigation, and --runs 5
is worth it on a machine you do not control.
Even so, the gap between 20% noise and the 30% regression the gate exists to
catch is not comfortable. Give this gate the quietest runner class you have,
and treat a single red --check on a shared runner as a prompt to re-run, not
as proof. The tolerance defaults are deliberately close to the regressions they
must catch; widening them further would make the gate decorative.
4. Enforce at runtime
# autumn.toml
[server]
capacity_contract = "capacity.lock"
With this set — and max_concurrent_requests unset — the load-shedding
admission ceiling is sourced from the contract's admission_limit instead of a
hand-tuned guess, and the binary sheds past it exactly as ADR-0009 describes —
but against a number someone measured rather than guessed. (admission_limit
is the measured knee plus headroom, floored at the host's CPU count; see
Calibrate for why the two numbers differ.)
Precedence, and the reasoning behind it:
- An explicit
[server] max_concurrent_requestsalways wins, including an explicit0(today's spelling of "shedding off"). An operator who set a number by hand outranks a file committed months ago. - Otherwise the contract, but only when it was measured on this host class,
was calibrated under the running profile, and records a non-zero limit.
The tool normalizes profile names before it compares them (
productionbecomesprod). If the running profile has no name, it skips this check. - Otherwise the profile default: primary pool size × 32, at least 256,
under
prod. Unlimited under every other profile.
Every failure along the contract path — missing file, malformed document, a
newer schema version, a contract from a different host class or profile —
degrades to the profile default with a warning (unlimited outside prod),
never to a lower ceiling. Failing closed here would mean a typo'd path or a stale lockfile sheds every request on the way up, turning a
capacity feature into an outage. The same reasoning covers a recorded limit of
0: it is read as "no limit was proven", never as "shed everything".
The environment-variable spelling is AUTUMN_SERVER__CAPACITY_CONTRACT.
Per-route resource shape
The contract records more than an aggregate number: each route carries the resource shape derived statically from the route graph.
| Shape | Meaning |
|---|---|
db-bound | Holds a database connection for the request; concurrency is bounded by pool checkout, not CPU |
io-bound | Holds a non-database external resource (mail, events, presence, notifications) |
compute-bound | Proves no pool — the honest reading of a handler declaring no resource extractor |
The shape is read off the handler's declared extractors at macro-expansion time, so it is proven rather than guessed:
#[get("/posts")]
async fn index(db: Db) -> Markup { … } // db-bound, pools = ["db"]
#[get("/about")]
async fn about() -> Markup { … } // compute-bound
Routes generated by #[repository(api = "…")] are db-bound by construction —
their handlers own the pool checkout internally, so no extractor appears in a
signature a macro could read.
What the shape does not claim
It is a provable subset, never a complete accounting. A pool reached through
an application-held State value, or behind a type alias that hides the
extractor's name, is invisible to it and the route reads as compute-bound. A
route with no pools is therefore normal, not a defect — the same "provable"
caveat the security dimensions of autumn routes audit carry.
The shape narrows why an envelope moved. The envelope itself is measured, not inferred from the shapes.
The guarantee, and its assumptions
The guarantee. For a build whose capacity.lock was calibrated on this host
class, the recorded sustained_rps and p99_latency_ms are what the binary
sustained at the saturation knee under the recorded seeded profile, and the
runtime will admit at most admission_limit concurrent requests, shedding the
rest with 503 + Retry-After rather than degrading past the envelope.
The assumptions it rests on. Every one of these is a way the number can stop describing your production system:
- Single binary, single host. The contract says nothing about a fleet. Cluster-wide aggregation and autoscaler integration are explicitly out of scope for this slice.
- The host class must match. CPU count, architecture, and OS are compared; memory is recorded but not gated, because it is unavailable on some platforms and varies with container accounting. Noisy neighbours on a shared runner are not modelled at all.
- The measured workload is the calibratable subset. Unauthenticated,
parameterless
GETroutes. If your traffic is dominated by authenticated writes, the envelope describes a workload you do not run. - The transport is plain HTTP over loopback TCP. Unix sockets are switched off for the calibration child, and a profile that terminates TLS in-process cannot be calibrated directly — calibrate under a profile that does not, and treat the envelope as excluding TLS handshake cost.
- Loopback is not your network. Calibration drives
127.0.0.1, so a request's in-flight lifetime is essentially handler time, while in production it also spans reading the body off the wire. The 2x headroom onadmission_limitis a blunt correction for this, not a measurement of it. - The contract is not re-validated against the running route graph. The
runtime checks the host class, not whether the app still has the routes the
envelope was measured against. A
capacity.lockleft uncommitted-to for months, against an app that has since grown forty routes, is enforced verbatim. Re-calibrate when the shape of the app changes; the[[routes]]section androute_graph_digestare there so the diff shows you when it has. - No external dependencies are calibrated. A run measures your process. If your database saturates before your app does, the contract records your app's ceiling, not your system's.
- Calibration is empirical, not symbolic. This slice is static-shape-derivation plus one calibration run, not a worst-case proof. Rust is what makes the envelope stable enough to be worth recording — no GC pauses, no JIT warmup, visible allocation — but the numbers still come from measurement.
- A dirty tree is recorded, not rejected.
git_dirty = truein the provenance means the contract describes a build nobody else can reproduce. Treat it as a draft.
See also
- ADR-0009 — Overload protection via load shedding
- Cloud-Native Guide — probes, graceful shutdown, and the rest of the production posture
- Dev-Loop Latency Budget — the sibling budget-and-gate story for the edit–refresh loop