---
title: "ADR-137: One candidate resolution for the primary's chain"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-137@0.35"
source: "Adr/Adr137OneCandidateResolution.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-137: One candidate resolution for the primary's chain

-   *Status:* Accepted
-   *Date:* 2026-08-09

## Context

The walk over a primary configuration's fallback chain existed twice:
`\Provider\Middleware\FallbackMiddleware` for pipelined calls and
`\Service\Streaming\StreamingDispatcher` for streamed ones. Both applied
the same four rules from ADR-021 — shallow, no self-retry, missing entry
skipped, inactive entry skipped — from two separate pieces of code, so a fix
to one was not a fix to the other.

The duplication is not free. `\Service\Tool\TrustZoneResolver` walks the
same chain to derive the data-class ceiling for tools (ADR-094). That ceiling
is only sound while the set a call may actually reach stays inside the set the
ceiling considered. Two independent loops are two chances for that to stop
being true.

Not every difference between the two was duplication, though. The
health-aware reorder (ADR-063) applies to the pipelined path only, and the
streaming path opens the primary itself while the middleware receives it
already attempted.

## Decision

The candidate loop moves into one class,
`\Provider\Fallback\FallbackCandidateResolver`, marked `@internal`. It
owns the four ADR-021 rules and nothing else:

1.  **It resolves, it does not order.** The caller hands in the chain it wants
    walked. The health reorder stays in `FallbackMiddleware`, where its
    single caller is; streaming keeps the configured order. No
    `RoutingPolicyInterface` is introduced —
    `ProviderHealthServiceInterface::reorder()` has exactly one caller
    repo-wide, and an interface with one implementation and one reader is a
    declaration nobody reads.
1.  **It does not own the primary.** `chainFor()` removes the primary's
    own identifier from its chain; whether the primary is itself a candidate is
    the caller's business. The middleware's primary has already run in the
    pipeline; the dispatcher prepends it because it still has to open it.
1.  **It does not log.** A skipped entry is reported to the caller through a
    callback carrying the identifier and a `FallbackSkipReason`. The
    middleware words the two reasons separately, the dispatcher collapses them
    into one line; merging the rules must not rewrite either log surface.
1.  **It resolves lazily.** Each entry is looked up when the caller asks for
    it, so an entry behind the one that served is never queried.

`TrustZoneResolver` is **not** touched. Its optional repository argument
is the fail-closed path: without a repository every chain entry resolves to
`null` and the zone falls to `EXTERNAL_GLOBAL`, the most restrictive
ceiling. Making it mandatory would trade that safety for symmetry and break
six test construction sites.

The invariant that ties the two together — the set either path attempts is
always a subset of the raw chain `TrustZoneResolver::zoneFor()` walks —
is pinned as a test
(`Tests/Unit/Provider/Fallback/CandidateResolutionTest.php`), not as a
shared class. A shared class would have to own both the ceiling and the
routing, coupling a security decision to a retry policy.

## Consequences

-   Both call sites take the resolver instead of
    `LlmConfigurationRepository`; neither queries configurations itself
    anymore.
-   The two deliberate differences are now asserted per path, including a
    reflection assertion that the dispatcher has no health-service dependency —
    wiring one in is a routing change and fails that test.
-   Streaming no longer resolves the whole chain up front. When an early
    candidate serves, later entries are no longer looked up and a broken entry
    behind it no longer produces a skip warning. The pipelined path already
    behaved this way; the eager resolution was an artefact of building an array,
    not a decision. The trade-off is that a typo'd chain entry is now reported
    only once the primary fails, so it is pinned as a dispatcher-level test
    (`theStreamingPathLooksUpNoChainEntryWhileThePrimaryServes`) — going
    back to an eagerly built array fails it.
-   ADR-021's rules now have exactly one implementation. A change to them is a
    change to one class, and both paths inherit it.
