---
title: "ADR-111: Tool side effects and fail-closed audit for writes"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-111@0.35"
source: "Adr/Adr111ToolEffectAndWriteAudit.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-111: Tool side effects and fail-closed audit for writes

-   *Status:* Accepted
-   *Date:* 2026-07-23
-   *Authors:* Netresearch DTT GmbH

## Context

The agent queue is at-least-once ([ADR-102](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-102@0.35)). A worker renews its
lease only at a step boundary, which fires *after* an operation completes, so a
provider or tool call that outlives the `LEASE_SECONDS` lease is reaped and the
run re-executed ([ADR-104](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-104@0.35)). The ownership guard on `finishRun`
prevents a double *settle*, but not a double *execution*: a tool's side effect
can land twice.

That is harmless for the tools shipped today — all are read-only. It is not
harmless for the WRITING tools the roadmap will add. Two gaps must close *before*
the first write tool ships:

1.  `AgentRunPersister::recordStep()` is fail-soft — a store hiccup is logged
    and swallowed. A tool can therefore execute with its audit event never
    persisted. For a write, that is an unrecorded mutation the run then reports
    success over.
1.  Nothing tells the runtime whether re-running a reaped operation is safe.

## Decision

Classify a tool's side effect and act on it.

### `ToolEffect`

A three-value enum: `READ_ONLY`, `IDEMPOTENT_WRITE`, `NON_IDEMPOTENT_WRITE`.
A tool declares it by implementing `ToolEffectInterface::getEffect()`. A tool
that does NOT implement it is `READ_ONLY` — the opt-in default that keeps all
shipped builtins unchanged, and the reason a write must declare itself rather
than be inferred. The value is a property of the CODE and is not configurable, so
an administrator cannot relabel a write to dodge the guarantees. Resolution BY
NAME (`ToolEffectResolver::effectFor()`) is stricter: an unknown name — a
stale or removed tool referenced in a persisted step — resolves to
`NON_IDEMPOTENT_WRITE`, the class that is both audit-critical and never
auto-retried.

### Fail-closed audit for writes

`recordStep()` still never throws, but now RETURNS whether it persisted. The
runtime fails a run whose WRITING tool executed but whose audit event could not
be stored — `AuditPersistenceFailedException` — rather than continuing over
an unrecorded write. Read-only and non-tool steps keep the fail-soft behaviour:
a transient database blip does not fail an observe-only run.

### No auto-retry for a failed write

`AuditPersistenceFailedException` carries no `FailureClass` of its own, so
`FailureClassifier` maps it to `UNKNOWN` — deliberately not retryable
([ADR-095](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-095@0.35)). A queued run is dead-lettered (`NOT_RETRYABLE`); an
interactive run settles `FAILED`. Re-running would re-execute the write, which
already ran once.

## Consequences

-   The classification is the precondition, not the whole story. Two follow-ups
    build on it: withholding auto-retry from a `NON_IDEMPOTENT_WRITE` whose *own*
    execution (not just its audit) was interrupted, and a lease-extension-before-op
    (`operation_deadline`) so a long write is not reaped mid-call. Both need this
    enum first.
-   `ToolEffect` is a separate opt-in interface, like `ToolDataClassInterface`
    ([ADR-094](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@0.35)); promoting it onto `ToolInterface` is a later,
    announced breaking change.
-   No builtin writes yet, so in production `recordStep` remains effectively
    fail-soft today — the machinery is in place for the first write tool, which is
    exactly when it must already exist.
