ADR-149: A criteria-mode trust zone comes from the resolved model
- Status
-
Accepted
- Date
-
2026-08-11
- Amends
-
ADR-144 (where a criteria-mode zone comes from)
- Authors
-
Netresearch DTT GmbH
Context
ADR-144 shipped with a known hole and named it as its own
revisit trigger.
Trust read
Llm, which reads through the configuration's
model relation. A criteria-mode configuration has none — model_uid = 0, the
model is chosen at call time — so every such record answered
EXTERNAL_GLOBAL, however local the model routing actually picked. A
configuration that only ever selects an on-premise Ollama could not be given a
confidential snippet. That is issue #723.
This is the same defect ADR-143 fixed one axis over: the context window was also sized from the configuration's relation and also found nothing there. The answer is the same shape — take the model that will actually serve the call.
Decision
The zone comes from the serving model when one is known.
zone takes an optional resolved
Model
and reads the primary
zone from ITS provider. Passing nothing keeps the configuration's own relation,
which is the fixed-mode case and what every existing caller had.
Fixed mode is untouched, structurally. In fixed mode the configuration's
provider is the model's provider —
get is a delegation to
the relation — so a resolution there could only return what the gate already
had.
Llm therefore resolves for the gate ONLY in criteria
mode. The invariant is a branch, not a coincidence.
A routing failure stays a routing failure. Criteria that match no model, or
match only models that cannot serve this operation, throw during resolution.
That exception belongs to the dispatch that follows — which resolves again and
raises it with its own semantics — so the resolution done for the gate swallows
it and hands the gate no model. With no serving model there is no serving
provider, and EXTERNAL_GLOBAL remains the honest answer. It is also the
answer this path already gave, so nothing is newly refused.
The gate does not drive routing. Resolving inside
Input, or reaching
Routing from
it, would invert the dependency: a governance check would decide which model
serves a call. The model is threaded in from
Llm, which
already knows the operation and owns the dispatch. The gate stays a consumer of
a decision it cannot influence.
One operation, one selection. The manager resolves with the SAME
Provider the terminal will use, which is the
ADR-138 rule for two resolutions of one call. A gate judging a
model the send never runs on would be worse than the fail-closed answer it
replaced.
What is deliberately not widened
The fallback hops.
zone still reads each fallback
configuration's own relation, so a criteria-mode fallback still contributes
EXTERNAL_GLOBAL. Resolving a model per hop would run the routing decision
once for every entry of a chain that may never be walked, to answer a question
about a provider the call may never reach. Fail-closed is the right default for
a hop that has not happened.
:php:`ceilingFor()`, and with it the ADR-094 tool gate.
Tool still asks
zone with the configuration alone,
so a criteria-mode run is still offered only the tools an external zone permits.
That leaves the two directions of one axis briefly asymmetric, which is stated
here rather than hidden: the READ side needs the model at a different point in
the run — tools are selected before the loop, and the loop's own resolution is
not this seam — and widening a signature for a caller that would have to be
rearranged first is the declaration-nothing-reads shape. The asymmetry is
fail-closed in the direction that matters: the tool gate offers LESS than it
could, never more.
Consequences
✓ A criteria-mode configuration that routes to a local model can carry a classified snippet. The ADR-144 hole is closed for the primary provider.
✓ The refusal is checkable: the context_blocked audit row now names the
provider and model the zone was read from, instead of the empty relation a
criteria-mode record has.
◐ A criteria-mode send resolves its model twice on the pipeline path — once for
the gate, once in the dispatch — and three times on the streaming path, where
Llm resolves once more for
its eager capability check before the opener resolves again. In the default
routing mode each of those is one extra
find query and an in-memory evaluation per send, against a call
that is about to cross a network to an LLM. Memoising the decision per call
would remove the extras, but that changes the routing invariant
(ADR-142) rather than this gate, and it is not built here.
Fixed mode adds nothing.
The cost is paid before the gate's own short-circuit, so an installation that has classified nothing — the majority, since the column ships undeclared — pays it for an answer the gate then discards. Resolving lazily would mean handing the gate a callable, which is the same inversion in slower motion: the gate would still be the thing that decides when routing runs. The cheaper answer is one resolution per call for everyone, and that lives in the planner, not here.
◐ Enforcement can now REFUSE where it previously refused for a different reason, and permit where it previously did not. An installation that had classified sources and criteria-mode configurations was refusing all of them; after this it refuses only the ones whose selected model is genuinely external. No configuration whose zone was read from a provider it actually reaches becomes refused.
One shape does newly refuse: a criteria-mode record that still carries a
model_uid from an earlier fixed-mode edit. The TCA displayCond on that
column hides the field when the mode is criteria; it does not clear the value,
and nothing else clears it either. Such a record used to be judged against that
stale relation — a call it never reaches — and is now judged against the model
the criteria select. Where the stale relation is local and the criteria select
an external model, a send that used to be permitted now throws.
✕ Not a guarantee about the model that answers. The permit is computed from the
gate's resolution; the send runs on the dispatch's own. The window between the
two is the one ADR-138 already lives with, but what falls through it is no
longer a mismatched cache key or a capability check against the wrong adapter —
it is a classified snippet reaching a provider the gate never approved. It does
not take a change in the model set, either:
Candidate feeds
measured quality, health and cost signals into the ordering for
every mode that uses them, so the winner can move between two resolutions of
one call. Before this record criteria mode was always EXTERNAL_GLOBAL, so
the disagreement had no governance consequence at all.
Revisit when
The tool gate needs the same treatment. It is the other half of the axis and the asymmetry above is a debt, not a design; the work is finding the point in the tool loop where the serving model already exists.
The fallback chain needs its real zones too — that is the same question this record answers for the primary, and it needs a cheaper resolution than one routing decision per hop before it is worth answering.
A per-call memo on the routing decision is now the answer to the ✕ above and not only to its cost: one resolution per call closes the window in which the gate's model and the dispatch's can disagree. It changes the routing invariant (ADR-142) rather than this gate, which is why it is not built here, but it is the change this record most wants.
A criteria-mode record's leftover model_uid is load-bearing in two
directions now — it is what the pre-ADR-149 zone was read from, and it is what
a switch back to fixed mode restores. Clearing it on the mode switch, in a
DataHandler hook or an update wizard, would remove the newly-refusing shape
above; it is not done here because it changes what a mode switch does to stored
data, which is a decision about the record, not about the gate.