ADR-078: Budget pre-flight for the specialized image/speech services
- Status
-
Accepted
- Date
-
2026-07-17
- Authors
-
Netresearch DTT GmbH
Context
Chat-shaped calls run through the provider middleware pipeline, so
Budget (ADR-025) enforces per-user and
per-configuration spend ceilings before any provider request. The
specialized image/speech services (DALL-E, FAL, Whisper, TTS) dispatch
HTTP directly from Abstract and bypass that
pipeline.
ADR-057 gave the specialized option DTOs the
be/planned fields (via Budget /
Budget) and wired them into usage attribution
— but deliberately deferred enforcement, recording that "routing these
services through a budget pre-flight is a separate decision with its own
trade-offs (no token cost model for FAL, multipart flows)" and leaving
planned carried-but-unused on the send path. This ADR is that
deferred follow-up.
Decision
Add a pre-flight budget gate to Abstract. A new
protected `enforce`: it
resolves the named configuration (reusing the existing
find), calls Budget
and throws Budget before any HTTP dispatch when a
limit is exceeded. Each concrete service calls it after option resolution
and input validation, before building its request:
Dall—EImage Service generate,() generate(from the options), andMultiple () create/Variations () edit(from the scalar() be, no cost/configuration).User Uid Text—To Speech Service synthesize(() synthesize/To File () synthesizedelegate to it, so they inherit the gate without double-gating).Long () Whisper— all three entry points (Transcription Service transcribe,() transcribe,From Content () translate); gating only some would leave an enforcement bypass.To English () Fal—Image Service generate/() generate, readingMultiple () be/User Uid plannedfrom its allow-listed options array (a newCost extractmirrors the existingPlanned Cost () extract; the payload builder drops both, so neither reaches the FAL API).Be User Uid ()
Deep was initially left out because its options carried no
budget fields. That exception did not survive review: a paid external call
no cap can stop is not defensible, whatever the shape of its options.
Translation now threads planned and configuration
through alongside the already-threaded be, and both translate
and translate run the same pre-flight as the image and speech
services. (Amended 2026-07-20; the original decision is kept above so the
change of mind is visible rather than rewritten away.)
Fail-open by construction. The gate hangs on a new optional trailing
constructor parameter ?Budget. It
autowires from the existing Budget alias in production;
when absent (unconfigured deployments, unit tests) enforce is a
no-op. Even when wired, Budget short-circuits to
"allowed" for calls without a backend user or without configured limits, so
nothing changes until an operator actually sets a cap.
Consequences
- Behavioural change, gated on configuration. Deployments that already
set per-user or per-configuration limits will start receiving
Budgeton image/speech calls once a cap is hit — previously those calls were only attributed (ADR-057), never blocked. Deployments with no limits are unaffected.Exceeded Exception - FAL and DALL-E variations/edit have no cost model. FAL publishes no
static price list and the variations/edit endpoints carry no
planned, so cost caps cannot trip for those paths — only request-count/token caps enforce. Operators must not assume a cost ceiling protects FAL image spend.Cost - Catch surface.
Budgetlives inExceeded Exception Exception\(the shared budget exception, ADR-025), notSpecialized\. Consumers that catch onlyException\ Specializedwill not catch a budget denial — this is the intended shared-exception behaviour.Service Exception - Consumers that hand-rolled their own specialized pre-gate (e.g. an
extension guarding image/TTS spend itself) can drop it;
checkhas no side effects, so a transitional double-gate is idempotent and harmless.() - The optional constructor parameter is a semver-minor change in the 0.x line, consistent with ADR-052/ADR-057; implementers that construct the specialized services by hand gain a nullable trailing argument.