ADR-134: A builtin's declared write effect implies human approval
Context
Two declarations describe a tool that changes something, and until now they were unconnected.
Tool (ADR-111: Tool side effects and fail-closed audit for writes) says what a tool does to the world.
It feeds the write fence, the lease, the audit step and the retry decision — four
readers, none of them about authorisation.
Requires (ADR-084: Human-in-the-loop tool approval with suspend and resume) says a human must approve the
call before the loop executes it. It is the only thing the approval scan in
Tool looks for, and no production class implements it: the
sole implementers are two anonymous test classes.
A tool could therefore declare NON_IDEMPOTENT_WRITE and still run
unattended. The two statements would have to be made in the same commit by
someone who knew both existed, and nothing failed if only one of them was.
The effect declaration is the better of the two to key on. It is a property of the code and deliberately not configurable (ADR-111: Tool side effects and fail-closed audit for writes): an administrator cannot relabel a write as a read to dodge the audit, which is exactly the property an authorisation input needs. The marker is an opt-in nobody has yet opted into.
Decision
A tool counts as approval-bound in the loop's approval scan when it implements
Requires; when it is a remote tool whose operator
declaration says so; or when it implements Tool, its
getEffect() is a write, and it is not remote.
Both write cases qualify. isWrite() is the predicate, not idempotency —
whether a repeat compounds governs retry, not whether a human should have seen
the call in the first place.
Every check is an instanceof or a getter on the tool the scan already
fetched. No resolver, no repository, no new dependency on the loop: nothing
here needs the registry-wide view
Tool exists to provide, and its unknown-tool fallback
(NON_IDEMPOTENT_WRITE) would turn every unregistered name into a suspend
instead of the refusal the invocation path already gives it.
The pre-existing fail-closed rule is unchanged: only an offered tool suspends. A registered-but-not-offered tool named by a model steered through injected prose still falls through to the invocation gate, which refuses it — there is no spurious approval prompt for a tool the run never allowed.
A remote tool's effect is not its consent
The effect coupling stops at the remote boundary, and that is load-bearing, not a convenience.
Mcp returns NON_IDEMPOTENT_WRITE for every
imported tool, a pure search tool included. That value is a fail-closed
assumption about a body this codebase cannot inspect (ADR-116: Central tooling authority — nr_llm owns builtin + MCP tools), not the
tool's statement about itself. Treating it as one would suspend every MCP tool
on every call and leave the shipped MCP client unusable.
The remote axis therefore has a source of its own, and it is not the server: the
readOnlyHint annotation is stored verbatim for display and read by no
resolver, because a remote server must not be able to influence its own
authorisation. It is an operator-declared, server-level column, added below.
The operator declares it, per server
tx_nrllm_mcp_server.requires_approval is that column, built like
data_class beside it (ADR-094: Tool data classes and provider trust zones): the operator declares it, the server
never does, and there is no code here to derive it from.
It differs from data_class in having a default, and defaults to
1 — approval required. A data class has no safe guess, so an undeclared
server is inert instead. A yes/no does have one: a server nobody has judged
asks first. The reading is fail-closed the whole way down —
Mcp treats only a literal 0 as
"no approval", so a missing column, a NULL, an empty string or a value from a
schema this version does not know all come back as "required". The alternative
would be a byte this code cannot read letting an unattended remote write
through.
The flag reaches the scan on the tool, not through a lookup.
Mcp already builds every Mcp from the very server
record that carries it, so it is a constructor argument, surfaced by
Remote. The scan runs once per tool
call in the loop; giving it a repository would put a query on that path and a
persistence dependency into a class that must not know MCP exists.
Remote extends Remote deliberately.
A free-standing "declare your own approval" interface would quietly make a
write-without-approval builtin expressible again, which the last section of
this ADR says is not to be. Extending the remote marker means a class cannot
reach for the declaration without also claiming that its behaviour lives
outside this codebase — the one case in which an operator declaration beats
reading the code.
Every server requires approval, existing ones included. The default of 1
lands on every pre-existing row when the schema updates, and nothing corrects
it afterwards: there is no upgrade wizard, and the state after an update is the
state after a fresh install. The assurance therefore rests on the schema alone,
which is what Mcp pins by dropping the column,
writing a row the way the previous version did, and running the add/change
migration Package runs.
A pinned install was the alternative, in the shape of
ADR-113: Fail-closed tool data-class enforcement switch/ADR-115: Tool data-class enforcement is the default for new installs: a wizard that writes an explicit 0 on the
servers already importing tools, so a new default cannot stop an integration
that runs today. It was written and then removed. MCP here is planned and not
yet in production use, so the running integrations such a wizard preserves do
not exist, and what remained was a fail-open path through the very assurance
this decision introduces — one that, matching on the value rather than its
origin, could not tell a 1 the schema wrote from a 1 an operator chose,
and so would have switched approval off on a server nobody had judged.
Turning the flag off is an operator's decision, taken per server once they know what that server's tools do. It is a tick in the record, not something an upgrade does on their behalf.
Registration bans the implicit combination too
Tool already refuses a tool that implements both
Requires and Requires
(ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)): the approval-resume path carries no user input, so the
combination is unsupported. This decision makes a declared write a second way to
be approval-bound, and the ban therefore extends to it — a non-remote,
write-declaring tool may not implement Requires either.
The extension is not defensive tidying. Without it the combination is not "handled by the runtime", it is dead:
- The approval scan runs before the input scan, so the tool suspends
AWAITING_APPROVAL, neverWAITING_FOR_INPUT. Toolrefuses an input-requiring pending call ("requires user input that was not provided") — correctly, since the approval path carries no data.Loop Service:: resume () - The model re-requests, the approval scan binds again, and the cycle repeats: one operator decision spent per turn and the tool never executes.
submitInput()is unreachable. It requires statusWAITING_FOR_INPUT, and the approval suspension'sSuspendedcarries neitherRun State inputToolNamenorinputSchema.
The refusal in step 2 is the mechanism of the defect, not its handling: it is what makes the cycle permanent. Nothing in the run reports the cause, and the operator sees only a tool that asks and asks. A registration failure at container boot names it.
What this costs is real and is the cost ADR-105: Typed user-input suspension (WAITING_FOR_INPUT) already accepted: a tool that needs both a human's data and a human's consent cannot be built. The runtime has no combined approval+input pause — ADR-105 banned the combination rather than build one — so allowing the declaration would promise a flow that does not exist.
The registration predicate mirrors the loop's, remote exemption included, so it can never reject a tool the approval scan would have let through. It sits in the constructor, which sees only the compile-time builtins; provider-supplied tools (the remote ones) are exempt from the coupling anyway.
Consequences
●● A write that ships without the approval marker still pauses for a human. The declaration that already had to be right for the audit and the retry now also carries the authorisation, so the two cannot drift apart.
● Nothing changes for the tools shipped today. Every builtin reads — which
Tool pins — so the new branch is inert until the first
writer lands. That test stays the builtin list; its scope over
Tool is untouched, because a provider-supplied
tool must not be able to satisfy or break a guarantee about code in this
repository.
◐ The reasoning is in one place. A tool author declares an effect for ADR-111: Tool side effects and fail-closed audit for writes's reasons and gets the human-in-the-loop pause without knowing the marker exists.
✕ A builtin can no longer both declare a write and ask the user for typed input. The combination has no working runtime path, so it now fails at registration instead of livelocking at run time — but a genuine case for it has no workaround short of the combined pause below.
◐ A remote tool pauses when an operator says so, and never because of what its effect or its server claims. The judgement an MCP tool cannot supply about itself is made once, per server, by the person who connected it.
✕ An update can stop an MCP integration that ran unattended before. Nothing pins the old behaviour, so an existing server suspends its runs until an operator unticks the box. That is the cost of not having a path that switches approval off on rows it cannot tell apart.
✕ It is a per-server switch, not a per-tool one. A server whose catalogue mixes a search with a delete is approved on the coarser of the two, and the operator's only finer instrument is a second server entry. Per-tool declarations would have to be stored against catalogue rows the import rewrites, and nothing reads them yet.
Revisit when
A per-tool remote declaration is actually asked for — the coarseness above is a known consequence, not an oversight, and the catalogue table is rewritten on every import, so a per-row flag needs a reconciliation rule before it needs a column.
Also revisit when a tool genuinely needs both a human's data and a human's consent. That needs a combined pause — one suspension that collects the input and the decision together — which ADR-105: Typed user-input suspension (WAITING_FOR_INPUT) deferred. When it exists, the registration ban above drops for tools that use it.
Also revisit if a builtin ever needs to write without a pause. Today that is not expressible, deliberately: the way out is to not declare a write, which the audit and the retry would immediately make wrong. A real case for a write-without-approval builtin is a case for a third declaration, not for loosening this one.