ADR-134: A builtin's declared write effect implies human approval 

Status

Accepted

Date

2026-08-09

Amends

ADR-084 and ADR-105

Authors

Netresearch DTT GmbH

Context 

Two declarations describe a tool that changes something, and until now they were unconnected.

ToolEffectInterface (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.

RequiresApprovalInterface (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 ToolLoopService 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 RequiresApprovalInterface; when it is a remote tool whose operator declaration says so; or when it implements ToolEffectInterface, 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 ToolEffectResolver 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.

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 — McpServerRecord::approvalRequired() 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. McpToolProvider already builds every McpTool from the very server record that carries it, so it is a constructor argument, surfaced by RemoteApprovalInterface::requiresApproval(). 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.

RemoteApprovalInterface extends RemoteToolInterface 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 McpServerApprovalDefaultTest pins by dropping the column, writing a row the way the previous version did, and running the add/change migration PackageSetup 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 

ToolRegistry already refuses a tool that implements both RequiresApprovalInterface and RequiresInputInterface (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 RequiresInputInterface either.

The extension is not defensive tidying. Without it the combination is not "handled by the runtime", it is dead:

  1. The approval scan runs before the input scan, so the tool suspends AWAITING_APPROVAL, never WAITING_FOR_INPUT.
  2. ToolLoopService::resume() refuses an input-requiring pending call ("requires user input that was not provided") — correctly, since the approval path carries no data.
  3. The model re-requests, the approval scan binds again, and the cycle repeats: one operator decision spent per turn and the tool never executes.
  4. submitInput() is unreachable. It requires status WAITING_FOR_INPUT, and the approval suspension's SuspendedRunState carries neither inputToolName nor inputSchema.

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 ToolEffectCoverageTest pins — so the new branch is inert until the first writer lands. That test stays the builtin list; its scope over ToolRegistry::builtinNames() 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.