# ADR 0107: A rollback spends a declared budget, and the budget is a bound

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0107-a-rollback-spends-a-declared-budget.md

---

# ADR 0107 — A rollback spends a declared budget, and the budget is a bound

## Context

ADR 0102 lists the kernel's obligations as separate bullets: *"rollback of every
resource whose start was attempted"* and *"bounded drain, reverse-order close
and optional force under two absolute process-wide deadlines"*. The deadlines
belong to the phase list of `shutdown()`. Nothing said what a **rollback**
spends, and the gap was not noticed because it does not look like a gap: the
rollback calls `close`, `close` takes a context, and the context type declares
both deadlines optional.

Optional turned out to mean two different things at the two ends. The kernel
read it as "this path has no deadlines to give". `managedServerResource` read
it as `context.deadlineAt ?? now`, and then computed `now - now` — which is to
say it answered "your budget is zero". So a failed startup handed its server
`{ gracePeriodMs: 0, forceTimeoutMs: 0 }`, and three things followed:

- requests the server had already accepted died at the socket, unanswered;
- the rollback itself failed, because `withTimeout(forceStop(), 0)` cannot
  succeed;
- and that failure **replaced the diagnosis**. `start()` rejected with an
  `AggregateError` reading *"application startup and rollback failed"*, so the
  first line an operator read was shutdown machinery and the resource that
  actually broke was one entry down.

Measured, with one request in flight: the request was severed in 39ms and the
startup cause was buried. The path became ordinary rather than rare when
`reportHealth` during `start` began to be honoured — a required resource that
starts unhealthy now fails the startup, so rollback is a normal outcome.

There is a second defect underneath, and it predates all of this: the rollback
loop awaited each `close` with nothing watching the clock. Every other phase in
`shutdown()` is wrapped in `untilDeadline`. A resource whose `close` never
returns — a poller awaiting its own completion, a consumer resource with a hung
upstream — therefore kept a failed startup from **ever** reporting why it
failed. Handing that loop deadlines without watching them would have moved the
hang from "forever" to "forever" while reading as though it were fixed.

## Decision

**The budget is declared on the application.** `ApplicationConfig.shutdown`
(`ApplicationShutdownBudgetSchema`: `gracePeriodMs`, `forceTimeoutMs`, no
signal) is the budget the rollback spends, and the default for `shutdown()`
called with no options. A rollback happens inside `start()`, so there is no call
for a caller to pass options to; the budget has to live where the application
does. Making it also the `shutdown()` default keeps "how long may this
application take to stop" one number instead of two that can disagree.

**The budget is enforced.** The rollback's `close` sweep runs under a timer at
the force deadline. A `close` that does not finish is abandoned, reported as a
`close` failure, and the startup error remains the `AggregateError`'s `cause`.

**An absent deadline is not a spent one.** `managedServerResource` no longer
collapses absence into `now`. It omits the field and lets
`ShutdownOptionsSchema` — which already carries `.default(30_000)` /
`.default(5_000)` — apply its own defaults. This is the Zod-first rule applied
to a boundary: the schema owns these numbers, and a second copy in an adapter is
a copy that can drift. It also fixes the class of bug rather than one arrangement
of it, since a consumer or the conformance kit may legitimately build a context
with no deadlines.

## Consequences

**A failed `start()` can now take up to the budget to reject.** This is the
trade, and it is the reason this is an ADR and a `### ⚠️ Breaking changes`
entry rather than a bug fix. With nothing in flight the rollback still returns
at once — a grace period is a ceiling, not a sleep. With a request that never
finishes, the default 30s+5s turns a rejection that took milliseconds into one
that can take 35 seconds. An application that would rather fail fast declares a
smaller budget; that knob is why the ceiling is not a supervisor policy chosen
by the kernel, which ADR 0102 says it does not choose.

It is also what makes the bound testable. A bound that can only be exercised by
waiting out the default budget is a bound nobody can afford to test, and an
untested bound is not one anybody should rely on;
`packages/core/tests/application-reported-health.test.ts` proves it in
milliseconds by declaring a 20ms budget.

**The rollback remains one phase, not five.** It calls `close` and only `close`
— no `stopAdmission`, no `drain`, no `force` loop. `forceDeadlineAt` is still
passed because an adapter that self-forces inside its own `close` (the managed
server does) needs both ends to compute a budget from. The guide says this
plainly rather than describing the rollback as "a real shutdown", which it is
not.

**One deadline is shared by the whole sweep**, as ADR 0102 requires of the
shutdown loops: *"it cannot create a fresh timeout for each resource"*. A
resource reached after the budget is spent therefore sees a deadline in the past
and gets a zero grace — which is now the correct answer, because the budget
really is spent, rather than the previous answer to a question nobody had asked.
