ADR-157: The simulation covers the run, and answers for an actor
- Status
-
Accepted
- Date
-
2026-08-11
- Amends
-
ADR-145 (the simulator gains the remaining gate, an actor, and an audit decision)
- Authors
-
Netresearch DTT GmbH
Context
ADR-145 built a simulator on the Governance tab: pick a
configuration and a tool, and the page runs
Tool —
the call the runtime makes — and renders the answer.
It closed with two open items, both stated as consequences rather than as defects:
- the simulator covers the tool gate, and the input-context gate (ADR-144) "is answerable the same way and is not wired in yet". ADR-148 wired in the routing decision as a separate readout section, which answers "why this model" but does not participate in the simulator's verdict;
- the simulator answers for the operator running it, and "would this be allowed for an editor" needs a user picker, "which is a separate surface".
Both matter because a run is stopped by whichever gate refuses FIRST, and a page that says Allowed while the input-context gate would refuse the send, or while routing resolves no model at all, is not a partial answer. It is a wrong one, given to an operator who came to the page precisely to avoid guessing.
Decision
One verdict, four axes, every axis visible. The simulation asks the tool
gate, the input-context gate, routing eligibility and the approval requirement,
and folds them: ALLOW when every axis permits and no human decision is
needed, ALLOW + APPROVAL when a human decision is, BLOCK when any axis
refuses. Each axis keeps its own row, because the fix differs per axis — a tool
group, a data class, a model catalogue, an approval workflow — and a verdict
that could not say which gate decided would send the operator looking in four
places.
ALLOW + APPROVAL is a third outcome rather than a footnote on ALLOW. A
call that runs only after a human says yes is not the same as one that runs
unattended, and collapsing the two would make the approval axis invisible at
exactly the moment it decides.
The input-context gate gets a decision method, and the enforcement path uses
it.
Input resolves, compares and returns an
Input;
assert calls it, records the
governance event and throws. One rule, two callers.
Catching the exception in the simulator was the obvious cheap alternative and
is WRONG. Observe mode does not throw at all: for a configuration the runtime
records as context_blocked and lets through, a catch sees nothing and
reports "allowed". The decision object carries zone and enforcing
separately, so "the gate refused" and "the send proceeded" are both sayable.
An undeclared configuration reports a null zone and a null ceiling rather than
the values it would have resolved. Nothing was compared, so nothing is claimed
— the same discipline
Routing applies to fixed mode. It also
keeps the gate's early return: resolving a zone walks the fallback chain
through the repository, and the hot path must not pay for a comparison it
never makes.
The approval predicate becomes one resolver with three callers.
Tool is what
Tool's
approval scan,
Tool's boot validation and the simulator all ask.
The two copies it replaced had already drifted: the registry exempted every
Remote, including one carrying the
Remote declaration the loop honours — so a remote tool
the loop would suspend for approval was still registrable alongside
Requires, which is exactly the deadlock that check exists
to prevent. The shared rule closes it. No shipped tool implements the
combination, so nothing that registers today stops registering.
The actor is a backend user, resolved read-only. The picker offers the
backend users the rest of the backend offers, and the selection is resolved
through
Acting — the seam a queue worker
already uses to authorise for the user who queued its work
(ADR-083). Privilege is read from the fresh database record,
so the picker can lower privilege but never mint it. There is no session
switch, no execution as the user and no write of any kind.
Tool is unchanged. It is @api and takes
a raw
Backend; widening it to take an
Ai would be a breaking change to make a readout convenient,
and the resolver already produces exactly what the gate takes.
Three of the four axes are global, and the page says so. Routing reads the
model catalogue through
Model, which ignores
enable-fields and takes no user; the input-context gate compares a
configuration's declared classes against the trust zone it can reach; the
approval requirement is a property of the tool's declaration. Only the tool
gate reads the actor, through requiresAdmin(). The readout carries a scope
column stating this per axis. A simulator that answered identically for every
actor on three axes without saying so would imply a dimension that is not
there, which is worse than not offering the picker at all.
The picker offers users, not usergroups. A group is not resolvable to an
acting backend user, and no axis in this simulation reads group membership on
its own: the tool gate reads isAdmin(), and a real user brings their groups
with them from the database. A group entry would have been a control with no
reader.
A simulation is not audited. This is the decision the record owes an
explicit answer, because either choice is a behavioural difference. The
runtime writes a
Governance when it BLOCKS a call. A simulation
blocks nothing, so recording one would put rows into the audit for calls that
never happened, and the audit's only load-bearing property is that every row
is something the installation actually did. An operator reading
context_blocked rows to decide whether enforce is safe (the workflow
ADR-113 and the administration guide describe) would be
counting their own experiments.
The cost is real and is not hidden: "who checked what, and when" cannot be answered from the audit. The module is admin-only, the simulation performs no inference and spends no tokens, and every gate it asks is read-only — so the action being unlogged grants nothing. If a future requirement needs it, the answer is a separate simulation log with its own retention, not rows in the governance stream that the enforcement workflow reads.
The simulator lives in the tool module. Core may not import
Service\Tool (ADR-090, enforced by ModuleSeamTest),
and the thing being simulated is a tool call: the entry point takes a tool
name and half its collaborators are tool-module classes. The two core gates it
also asks are a dependency in the allowed direction.
Consequences
✓ An operator can answer "would this specific call pass the tool gate, the input-context gate, routing and the approval check, for this specific person" in one place, and see which gate decided.
✓ The approval requirement has one definition. Narrowing the remote exemption is one edit instead of three kept in step by a comment.
✓ The input-context gate can be asked without being triggered, and observe mode is reportable rather than invisible.
✓ No second policy engine, and no widened @api signature. Every axis is the
runtime's own service.
◐ The simulation does not resolve a serving model, so it asks
Input without one and gets the zone the
configuration's own relation gives. For a criteria-mode record that is the
fail-closed EXTERNAL_GLOBAL (ADR-149), while the runtime —
which has resolved a model by then — may read a weaker declaration as permitted.
The page is therefore stricter than the send it describes, never laxer: it can
warn about a refusal that will not happen, and cannot miss one that will.
◐ The verdict is a fold of four axes, evaluated together. The runtime evaluates them at different moments in a run, so a real call that fails the tool gate never reaches routing. The page reports all four regardless, which is more than the runtime would have said — deliberately, because an operator fixing one refusal wants to know whether the next one is waiting behind it.
◐ Routing is asked for a tool-calling operation, because that is the run a tool simulation describes. A configuration used for something else may route differently, and the readout below the simulator is where that is asked.
✕ Configuration access is not one of the four, and it is actor-scoped today.
Configuration (ADR-070) reads
backendGroupIds and refuses a group-restricted configuration for a
non-member; the picker's configuration list is unfiltered. That pairing
therefore reads Allowed here and is refused at runtime. Budget and
guardrails are outside the four as well, but neither is a pairing the picker
can produce. The docs page states the limitation next to the picker.
✕ Simulations leave no trace. See the audit decision.
✕ The picker cannot answer for a usergroup, a service account, or a frontend user. Only a backend user resolves to the identity the tool gate takes.
Revisit when
An axis becomes actor-scoped that is not today — a per-user model catalogue, or a configuration-access check folded into the verdict — or a requirement appears for a record of who simulated what. The first widens the scope column; the second is a new log, not a change to the governance stream.