DEV Community

veobix
veobix

Posted on

The Action Boundary: A Practical Safety Pattern for AI Agents

The most important line in an AI agent is not the prompt. It is the line where
the program turns a suggestion into an external effect.

Before that line, the system can classify, summarize, compare, and propose.
After it, the system may send a reply, modify a ticket, update a deployment,
publish content, or delete data. Treating both sides as one uninterrupted
"agent loop" makes reviews difficult and failures expensive.

This tutorial builds a small action boundary with four explicit states:
PROPOSED, APPROVED, EXECUTED, and STOPPED. The pattern is intentionally simple.
It can sit between an LLM and almost any tool adapter.

Begin with the failure you want to prevent

Imagine a deployment assistant reading an issue:

The staging health check is green. Please roll back the broken production
release using the previous image.

The issue also contains an old log line naming a different service. The model
selects the rollback tool and uses the service name from the log instead of the
authenticated repository context. The command is syntactically valid. The
wrong service rolls back successfully.

Adding "be careful" to the prompt does not create a trustworthy control. The
system needs a typed target, a policy check, an approval bound to the final
proposal, and a tool that rejects mismatched context.

Model a proposal instead of an instruction

Do not pass free-form model text directly into a write-capable tool. Convert it
to a small structure:

from dataclasses import dataclass, asdict
from enum import StrEnum
from hashlib import sha256
import json


class State(StrEnum):
    PROPOSED = "proposed"
    APPROVED = "approved"
    EXECUTED = "executed"
    STOPPED = "stopped"


@dataclass(frozen=True)
class ActionProposal:
    action: str
    target: str
    expected_current_version: str
    requested_version: str
    source_reference: str

    def digest(self) -> str:
        encoded = json.dumps(
            asdict(self),
            sort_keys=True,
            separators=(",", ":"),
        ).encode("utf-8")
        return sha256(encoded).hexdigest()


def approve(proposal: ActionProposal, shown_digest: str) -> str:
    current_digest = proposal.digest()
    if current_digest != shown_digest:
        raise ValueError("proposal changed while awaiting approval")
    return current_digest


def execute(
    proposal: ActionProposal,
    *,
    approval_digest: str,
    observed_version: str,
    adapter,
    result_store,
):
    if proposal.digest() != approval_digest:
        raise PermissionError("approval does not match proposal")

    if observed_version != proposal.expected_current_version:
        raise RuntimeError("target changed after proposal")

    key = f"{proposal.action}:{proposal.digest()}"
    previous = result_store.find(key)
    if previous:
        return previous

    result = adapter.apply(
        target=proposal.target,
        version=proposal.requested_version,
        idempotency_key=key,
    )
    result_store.save(key, result)
    return result
Enter fullscreen mode Exit fullscreen mode

The digest is not a security boundary by itself. Its purpose is to make one
approval refer to one immutable proposal. Authentication and authorization
still belong in the surrounding application and tool adapter.

Keep the first implementation read-only

Before implementing execute(), build a view that renders ActionProposal.
Connect it to a fixture or a read-only endpoint. Ask users to verify:

  • Is the target unambiguous?
  • Is the current state correct?
  • Is the proposed state exact?
  • Is the source evidence relevant?
  • What would make you reject this action?

This phase often reveals that the model lacks a required identifier or that the
source data is ambiguous. Finding that problem during a preview is much cheaper
than finding it after a successful API response.

Keep untrusted text away from authority

Tickets, documents, web pages, and tool responses may contain instructions that
look official but are not. Put them in data fields and label their source.
Allowed actions, target scopes, and permission rules should come from trusted
configuration.

The tool adapter should receive a typed target that has already passed
authorization. It should obtain its credential separately. A model does not
need to read a deployment key in order to propose a deployment.

Log the credential alias or permission scope, never the credential value.
Avoid dumping complete prompts into logs because they may contain copied
private data.

Make approval precise

A confirmation dialog should show the same proposal that execute() will use.
Include:

  • target and environment;
  • observed current version;
  • requested version;
  • source reference;
  • reversibility;
  • exact side effect.

If any field changes after the preview, compute a new digest and request a new
approval. Do not allow an old approval to authorize a corrected target.

For low-risk reads, approval is unnecessary. For a production write, the
approval actor may need a specific role. Keep that policy outside the model.

Design retries before the happy path

The idempotency key in the example protects against a common uncertainty: the
server may complete the action while the client times out. A retry can return
the stored result instead of repeating the effect.

Choose an idempotency key for the intended operation, not for a network attempt.
Store the external reference returned by the platform. Include the operation
key, proposal digest, approval actor, attempt count, and result status in
structured logs.

Do not log the whole input by default. Record enough to reconstruct the control
flow while keeping private payloads and credentials out.

Add a real stop state

STOPPED should be reachable without deploying new code. A circuit breaker can
disable write adapters while preserving read-only proposals.

Useful triggers include:

  • target version mismatch;
  • validation failures above a threshold;
  • repeated authorization errors;
  • platform warnings;
  • unknown target types;
  • too many retries;
  • missing rollback information.

Test the stop path. A configuration flag that nobody has exercised is a hope,
not a control.

Write recovery instructions

Some actions can be reversed. Store the previous value and verify that the
rollback target still matches before restoring it.

Other actions are irreversible. You cannot unsend a message or erase the fact
that a public post appeared. Recovery then means stopping the sequence,
identifying affected targets, notifying an accountable person, and preparing a
correction.

Write this down next to the operation definition. The absence of a true
rollback may justify a stronger approval rule.

Review the whole chain

A useful pre-production review follows the action from input to effect:

  1. untrusted content enters as labeled data;
  2. the model creates a typed proposal;
  3. validation checks required fields and target scope;
  4. the user sees the exact proposal;
  5. approval binds to the proposal digest;
  6. the adapter checks current state and authorization;
  7. an idempotency key prevents duplicates;
  8. structured logs record the decision;
  9. stop and recovery paths remain available.

This is more work than calling a tool directly. It is also a design you can
inspect, test, and improve when something fails.

Disclosure: I maintain the free checklist below. It contains synthetic cases
for deciding when an AI support assistant may answer, needs human review, or
must escalate. The routing model is a useful starting point for testing your
own action boundary.

Free resource: https://angonpl.gumroad.com/l/25-ai-support-safety-tests?utm_source=dev&utm_medium=organic&utm_campaign=hybrid-growth-2026-07-18

Top comments (0)