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:
Routing returns a
Routing 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.
Model runs the same
Routing 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
Effective. It is not,
because
Model already owns all four predicates the answer
needs — the fixed-vs-criteria branch, the stored criteria, the
Operation 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
constrained, 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.
Routing 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
?Routing 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:
Routingtakes an optional policy mode. Existing calls are unchanged.Decision Service:: decide () ModelgainsSelection Service Interface explain, because a controller cannot reach a concreteRouting () @internalservice's method through the interface it is wired against.decidedeliberately 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.
Routingstays private andDecision Service @internal; private services are injectable, and only a direct container fetch would have neededpublic: true.
Both enums gained label keys
Routing and
Routing had no labels,
because nothing rendered them. They follow
Governance — 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:
Routing 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
Routing 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
Operation 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:
Routing 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.