0058 — Agent actions must be reliable by construction: every tool an agent can call really does what it claims

Context

Goal: Clara's actions are trustworthy by construction — she can never tell a caller or PM that something happened unless the tool actually did it. Reliability here is not "fewer bugs"; it is the invariant that a promise Clara speaks is always backed by a real side effect. The failure mode below is what breaks that invariant; the taxonomy + guard restore it.

ADR-0057 found two tools on the unknown_caller voice path that looked like they acted but didn't: capture_unknown_caller_note (wrote a dashboard note nobody watched, no notification) and escalate_to_human (stamped transferredTo + said "Transferring call…" while placing zero telephony and paging no one below high urgency). It fixed those two by hand and fenced them with per-tool guards.

But the class is wider than two tools. A wider audit of 1,377 voice calls in ADR-0057's own context surfaced the same shape elsewhere (lost work orders, unsent renewal texts). The root failure mode is structural, not incidental:

An agent (Clara) is given a tool. The tool's name and the prompt tell her it does X. The handler does not do X — it records a row, returns a hardcoded success string, or no-ops — and Clara faithfully tells a human "X is done." The lie is invisible because the unit test mocks the handler and goes green.

The standing follow-up to ADR-0057 (this branch) removed the last hand-findable instance of the wording half: escalate_to_human no longer narrates a transfer it cannot perform, on any channel. With that done, the remaining work is to make the class impossible to reintroduce, rather than waiting for the next production incident to surface the next instance.

The mental model that organizes the fix (Fede/Jose, 2026-06-16): there are two kinds of "tool," and one of them should not be a tool at all.

Decision

Adopt a tool effect-class taxonomy for every tool surfaced to an agent, and enforce the ADR-0057 invariant per class through a three-layer ladder.

1. Effect-class taxonomy (a field on the agent-surfaced ToolSpec)

Every tool reachable by an agent (voice or SMS/text Clara) is classed:

The class lives on the spec so all three enforcement layers reference one contract.

2. Three-layer enforcement ladder

Layer 1 — Cheap CI structural fence (every PR, $0). Generalize the per-tool wording⇒delivery guard ADR-0057 hand-wrote (unknown-caller-promise-delivery.repro.test.ts, the escalate wording tests) into a single drift test over every agent-surfaced handler:

This makes "the agent promises X with no verifiable side effect" fail to merge, for all tools, not just the two we already caught.

Layer 2 — Deterministic reality eval against the appfolio-45 bench (daily canary + pre-cutover gate). The machinery already exists for the AppFolio L4 catalog: evals/run-tools-eval.ts fires each tool against the real bench and asserts a real response shape, and src/__tests__/tools-eval-fixture-coverage.test.ts fails CI if a catalog tool has no fixture. Extend this to the voice/agent webhook tools — fire the real handler → real integration and assert the side-effect actually happened. Crucially, the assertion must read the external source of truth, never PropFlow's own state (workflow-preference, Gera 2026-06-16, docs/planning/real-source-funnel-bench-handoff.md): a "message delivered" check queries the Twilio/SES API for the delivery, a "work order created" check queries AppFolio for the WO — it does NOT self-validate from the PropFlow DDB row that the handler just wrote, and never falls back to a local value on a read failure. Self-validation is how a silently-dropped message (Temporal down) passed green. Wire a daily run as a launchd canary alongside the spine-health / pms-sync canaries, and require it green before any live voice-tool cutover. This is the layer that catches "the handler is shaped right but the integration is dead" — which neither a unit test nor Layer 1 can see.

Layer 3 — Reviewer-lens rule (codifies the gate culturally). The cloud reviewer already enforces "no mock theater" (reviewer-lenses/evals-and-tests.md §H). Add one rule to the tools-platform lens: any tool surfaced to an agent must be real-sync (with a receipt + a Layer-2 reality fixture) or record-deferred (future-tense wording, no fake "done"); a handler that returns a hardcoded success/promise string with no verifiable side effect is a merge blocker, and a new record-deferred tool must justify why it isn't maestro-level deferred work. This puts the human/bot reviewer on the same contract the tests enforce.

Entity classification

No new entities. This ADR adds a classification field to existing ToolSpecs and three test/eval/reviewer surfaces. No data-model change.

Consequences

Alternatives considered

Steps to reproduce (the class this ADR closes)

In production (the ADR-0057 incidents, real callers, test data excluded): an inbound voice call routes to a sibling whose tool promises a human action; the tool records a row or returns a success string but performs no notification/transfer/send; Clara tells the caller it's handled; no human is reached. Pinned historically by src/__tests__/unknown-caller-promise-delivery.repro.test.ts + unknown-caller-escalate-no-transfer.repro.test.ts.

In the test suite (what each layer would add):

Out of scope here (separate tickets): the re-shaping of capture_unknown_caller_note from real-sync to maestro-deferred; the parallel promise-without-delivery gaps the ADR-0057 audit surfaced in other flows (lost work orders, unsent renewal texts, issueType misclassification).