---
title: "ADR-142: One routing decision, with a reason per candidate"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-142@0.35"
source: "Adr/Adr142OneRoutingDecision.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-142: One routing decision, with a reason per candidate

-   *Status:* Accepted (the trace deferral is settled; the complexity-routing deferral is measured but still open — see [ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35))
-   *Date:* 2026-08-10
-   *Amends:* [ADR-060](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-060@0.35) (quality is no longer only a separate hook)
-   *Amended:* 2026-08-11 by [ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35)
-   *Authors:* Netresearch DTT GmbH

## Context

Criteria-mode selection worked and had exactly one production path —
`ModelSelectionService::resolveModel()`, reached through
`ConfigurationCallPlanner`. What it could not do is say **why**.

`modelMatchesCriteria()` returned a bool. A model that never appeared in a
call was indistinguishable from one that lost on cost, and an operator asking
"why is it not using the model I added" had nothing to read.

Two signals also sat outside the decision, for different reasons:

-   `QualityAwareModelSelector` ([ADR-060](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-060@0.35)) is a documented
    opt-in hook whose own docblock calls first-class wiring a deliberate
    follow-up. Nothing in the core calls it.
-   `ProviderHealthService::reorder()` ([ADR-063](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-063@0.35)) reorders
    the FALLBACK CHAIN. That is a different axis — which provider to try next
    after a failure — not which model to select.

So this is not a merge of competing routers. There is one path; it gains an
explanation, and two measurements it never consulted.

## Decision

Discovery, then eligibility, then ranking — and the boundary between the last
two is the load-bearing part.

**Eligibility is a hard constraint with a reason.**
`EligibilityEvaluator` answers "may this model serve this call" and returns
a `RoutingRejectionReason` instead of `false`. It is the ONLY
implementation of that question: `modelMatchesCriteria()` now delegates to
it rather than keeping a second copy of the predicates.

**Ranking never revisits eligibility.** A rejected candidate carries no score at
all — not a low one — so there is no number for it to come back with. This is
why `RoutingCandidate` is either eligible-with-a-score or
rejected-with-a-reason and never both.

**The reasons have an order, and it matters.** The operator's own criteria are
evaluated first and the operation capability ([ADR-138](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-138@0.35)) last.
A caller reads `OPERATION_CAPABILITY_MISSING` as "would have served, but not
this operation", and `resolveModel()` raises a misconfiguration error on
it. Evaluated earlier, a model the criteria excluded anyway would report that
reason and the error would name a model nobody wanted.

**Measured signals are opt-in.** `RoutingPolicyMode::PROVIDER_PRIORITY` is
the default and reproduces the previous ordering exactly. `balanced`,
`quality` and `economy` add quality and health. Opting in is deliberate:
these signals change which model serves a call, and an installation that never
asked must not get it from a version bump — the shape `health.reorderFallback`
already uses.

**Provider priority outranks every measurement.** A priority is an operator's
instruction; a score is evidence. Evidence does not overrule an instruction, so
priority is a sort key ABOVE the score rather than a term inside it.

**Absent data is not bad data.** A model with no quality measurement contributes
nothing to the weighted mean rather than a zero, and a provider with no samples
in the window yields no health signal at all —
`ProviderHealthScore::NEUTRAL_SCORE` already established that rule for the
fallback chain. The consequence that makes the default safe: with no data, every
candidate scores identically and the established tiebreaks decide.

### Three named modes, not a weight panel

An operator can state an intent. They cannot reasonably calibrate four
coefficients against each other, and a backend full of sliders produces settings
nobody can explain six months later.

## What this does NOT do

**No request-complexity routing.** The plan this work came from lists it, and it
is deliberately not built: routing on an estimated complexity score needs
evidence that the score predicts anything, and that evidence does not exist yet.
Measuring it first, deciding later, is the same shape [ADR-138](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-138@0.35)
used for the operation-capability axis.

[ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35) does the measuring, and still does not route: it names
the three things that must hold before anything may.

**No persisted decision trace, yet.** A trace whose only reader is a future
analytics view is a declaration nothing reads. The decision object exists and is
returned; persisting it belongs with the surface that displays it.

That surface exists — [ADR-148](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-148@0.35) explains a hypothetical decision,
[ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35) persists the real ones and reads them back on the same
page — so the trace is now written.

**No change to fixed mode.** Nothing is chosen there.

## Consequences

✓ Every automatic selection can name the model it chose and the reason each
other active model was not chosen.

✓ Hard constraints cannot be overridden by a score, structurally rather than by
convention.

✓ The predicates exist once. `modelMatchesCriteria()` and the decision point
cannot drift apart, because they are the same code.

✓ The default behaviour is unchanged, and the whole existing test suite passing
untouched is the evidence.

◐ `QualityAwareModelSelector` still exists and still has no core consumer. It is
not deleted: it is documented public surface with its own semantics (a hard
`minQuality` FILTER, which the ranking deliberately does not have — a minimum
quality is a constraint, and constraints belong in eligibility). Whether it
should become one is a separate decision, and one this ADR makes easier rather
than answers.

◐ Collecting quality and health costs one lookup per candidate in the modes that
use them. The default mode collects nothing.

✕ The weights are judgement, not calibration. They are chosen to be defensible
and to keep the ordering stable, not because a measurement said 0.4 was right.

## Revisit when

Enough real decisions have been traced to say whether the modes correspond to
anything operators actually want, or whether one of them is never chosen.

Also revisit when a `minQuality` floor is asked for: that is an eligibility
constraint with a rejection reason, not a ranking weight, and it is the natural
moment to fold `QualityAwareModelSelector` in.
