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). 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). 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:
Agentis 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.Run Persister:: record Step () - 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 Tool. 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 (Tool) 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 — Audit — 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
Audit carries no FailureClass of its own, so
Failure maps it to UNKNOWN — deliberately not retryable
(ADR-095). 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_WRITEwhose 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. ToolEffectis a separate opt-in interface, likeTool(ADR-094); promoting it ontoData Class Interface Toolis a later, announced breaking change.Interface - No builtin writes yet, so in production
recordStepremains effectively fail-soft today — the machinery is in place for the first write tool, which is exactly when it must already exist.