ADR-122: The side-effecting tool contract waits for a side-effecting tool 

Status

Accepted

Date

2026-07-29

Authors

Netresearch DTT GmbH

Context 

The roadmap asked to promote the tool effect declaration (ADR-111: Tool side effects and fail-closed audit for writes) into a tool-facing interface with an idempotency scope and an optional preview, "so writing tools can be built against a contract instead of a convention".

Three facts decided this differently.

No tool writes. All 44 builtin tools read. None implements ToolEffectInterface; every one takes the READ_ONLY default. The write path exists and is correct — the lease-before-op fence, the fail-closed audit, the retry refusal — but nothing exercises it with a real tool.

The idempotency scope has no reader. The only place an effect crosses a process boundary is the pending_effect column, and its single consumer reduces it to one bit: may this run be retried. A scope value would be a field nothing branches on.

The preview has no caller and no display. The one surface that could show it is the approval card, and that is reachable only for tools implementing a separate marker interface, which declaring an effect does not imply. It would also have to run inside the reviewing administrator's request rather than the run's actor context, which ADR-083: Conversation sessions and memory forbids reading around.

Promoting getEffect() onto ToolInterface would additionally break every builtin and every third-party tool built against the public DI tag, for no behavioural gain.

Decision 

Do not build the interface, the scope or the preview yet. A contract designed before the first writing tool guesses at the shape that tool needs, and this codebase has just spent three changes removing exactly that kind of guess: an argument that looked like enforcement and was read by nothing.

Do the three things that are real today.

Clear the write fence on requeue. applyRequeueSet cleared the claim and the lease but left pending_effect standing. A requeued run has not started its next attempt, so the fence describes a write that is no longer in flight — and a standing NON_IDEMPOTENT_WRITE dead-letters the run whatever the retry budget says. Narrow but reachable: a step naming a tool the registry no longer knows resolves fail-closed to NON_IDEMPOTENT_WRITE, so a tool removed or renamed between attempts stamps the fence for real.

Pin which tools write. A new coverage test asserts the set of tools resolving to a write effect, currently empty. The declaration is opt-in, so forgetting it is silent and costs the tool its fence and its audit. The test turns that silence into a failing assertion the first time someone adds a writer.

Correct the roadmap. It claimed "at-least-once queue delivery with idempotent tool effects" as shipped. What shipped is a declared effect classification and a fail-closed write audit, with no writing tool to exercise either.

Consequences 

Nothing changes for the 44 existing tools.

The first writing tool will find the machinery waiting for it and the coverage test asking it to declare itself. Whether it then needs an idempotency scope, a preview, or something neither of those describes is a question that tool can answer and this one cannot.

Revisit when 

A tool that mutates is proposed. At that point the three deferred pieces should be reconsidered against what it actually needs — starting from the tool, not from this ADR.

One design constraint surfaced while investigating and is worth recording so it is not rediscovered: making a completed non-idempotent write safely replayable would require a tool-result dedup store, and that in turn changes the stale-run reaper's unconditional dead-letter policy. Those two move together or not at all.