ADR-148: The routing readout is a second gate on the Governance tab 

Status

Accepted

Date

2026-08-11

Amends

ADR-145 (the simulator gains the second gate it named)

Authors

Netresearch DTT GmbH

Context 

ADR-142 made an automatic model selection explainable: RoutingDecisionService::decide() returns a RoutingDecision that carries the selected model, every ranked candidate with its score and signals, and every refused candidate with its reason.

Nothing read it. The decision was produced on every criteria-mode call and thrown away except for ->selected, so the question an operator actually asks — "why this model and not that one" — had no answer anywhere in the backend.

ADR-145 built the shape for answering such a question: a read-only surface that calls the runtime's own gate and renders what it says. It closed by naming the routing decision as a second gate that was "answerable the same way and not wired in yet". This record wires it in.

Decision 

A section on the Governance tab, not a tab of its own. ADR-145 already established that page as the place an operator asks "which rule applies here, and what would it say about this call". A second tab would need its own module route, its own doc-header entry and its own template, and would split one question — "why does this configuration behave like this" — across two pages. The section is admin-only for the same reason the rest of the page is: the module registration in Configuration/Backend/Modules.php is access: admin.

It calls the real decision point. ModelSelectionServiceInterface::explainRouting() runs the same RoutingDecisionService::decide() the runtime runs. There is no second ranking, no second eligibility check and no second reading of the enforcement switch. A readout with its own copy of the rules would be worse than none, because the two can disagree and only one of them runs.

The readout lives on the selection service. It could have been a separate readout service in the shape of EffectivePolicyReadout . It is not, because ModelSelectionService already owns all four predicates the answer needs — the fixed-vs-criteria branch, the stored criteria, the OperationCapabilityMap lookup and the routing.operationCapabilityEnforcement switch — and a separate service would have had to own second copies of every one. The rule that the operation capability joins the criteria only while enforcement is on now exists once, in constrainedCriteria() , read by both the resolution and the readout.

Fixed mode is reported as no decision. A configuration that names its model chose nothing: there are no candidates, no ranking, no policy mode and no rejection reasons. RoutingReadout therefore has two states, and every field that describes a decision is null in the fixed one. Rendering a fixed configuration as a decision with a single winning candidate would invent reasoning the runtime never performed, and an operator would then debug criteria that are not consulted.

Trying a policy mode changes nothing. decide() gained an optional ?RoutingPolicyMode argument. It is evaluated for that one call; the install setting is neither read nor written, and the next call without it is back to the configured mode. The alternative — writing the setting and reading it back — is the apply path ADR-140 argued down, for the same reason: writing extension configuration rewrites the whole merged array.

The narrowest widening that makes it reachable 

Three changes, and no more:

  • RoutingDecisionService::decide() takes an optional policy mode. Existing calls are unchanged.
  • ModelSelectionServiceInterface gains explainRouting() , because a controller cannot reach a concrete @internal service's method through the interface it is wired against. decide() deliberately stays OFF the interface: it takes a raw criteria array and knows nothing about the fixed-vs-criteria branch, so a controller calling it would be choosing which half of the rule to apply.
  • Nothing changed in DI. RoutingDecisionService stays private and @internal; private services are injectable, and only a direct container fetch would have needed public: true.

Both enums gained label keys 

RoutingRejectionReason and RoutingPolicyMode had no labels, because nothing rendered them. They follow GovernanceProfile::getLabelKey() — including its get… prefix, which exists because Fluid reaches a method only through the get/is/has convention and a plain labelKey() yields an empty translation key that throws at render time.

RoutingDecision::noCandidates() is deleted 

It was dead: decide() never called it. Giving it a caller would have been decorative — on the empty-catalogue path decide() already constructs the byte-identical value, so the named constructor was a synonym rather than a distinction. The distinction is real, though, and it now has a reader at the other end: RoutingReadout::isEmptyCatalogue() separates "no active model was even considered" from "every candidate was refused", because the two need opposite fixes and the page says which one happened.

Consequences 

✓ An operator can answer "why this model", "why not that one", "what would economy mode pick" and "is the operation-capability switch actually enforcing" — from the runtime's own decision, on the page that already answers the governance questions.

✓ A fixed-mode configuration is answered honestly: nothing was decided, and the page says so instead of manufacturing a one-candidate decision.

✓ The signals table distinguishes "no data" from a measured zero, which is the distinction RoutingCandidate carries and the one a Fluid conditional would have destroyed. The flattening happens in the controller, as the dashboard already does for its bar widths.

◐ The readout answers for the configuration and operation the operator picks, under their own backend session. It does not simulate another user — routing has no per-user axis today, so unlike the tool gate there is nothing a user picker would change.

◐ Only the operations that OperationCapabilityMap maps to a capability are offered. The rest constrain nothing, and offering them would promise a dimension the decision does not have. "No operation selected" is reported as its own state rather than as an operation that requires nothing: RoutingReadout carries whether one was named alongside the capability it required, because a null capability has both causes and one sentence for the two would describe an operation the operator never chose.

✕ No apply path, and no way to persist a tried policy mode from this page. ADR-140's reasoning is untouched.

Revisit when 

Routing gains a per-actor or per-site axis, or the decision becomes worth persisting per call rather than recomputing on demand.