SafeExec

Five things that break when an agent meets a real system (with Spring Boot code)

September 2026 · the failure modes, the code, and the tests

Not a framework and not a model wrapper: the boring layer between "the model said so" and "money left the account".

I spent the last few weeks building a purchasing agent against a supplier client that fails on purpose — timeouts after the side effect landed, lookups that lag, crashes between the call and the commit. Every problem worth solving turned out to have nothing to do with the model. They were all the old problem of calling an external system from a program that decides on its own, which is the same problem a payment gateway integration had ten years ago, except the arguments are now generated.

Here are the five that mattered, each with the code that closes it and the test that proves it.

1. The request timed out. Did you just purchase twice?

A SocketTimeoutException after createPurchase has three possible truths: the request never arrived (resend is safe), it arrived and was processed and the response was lost (resend is a duplicate), or it is still being processed. The client cannot tell them apart. So the first rule:

UNKNOWN ≠ FAILED

After a timeout the attempt is UNKNOWN, meaning "a side effect may exist". Only an explicit refusal from the other side is DEFINITIVE_FAILED:

} catch (DefinitiveFailureException e) {   // supplier said no (4xx-style)
    intents.markDefinitiveFailed(attempt.getId(), e.getMessage());
} catch (RuntimeException e) {             // anything after send: side effect possible
    intents.markUnknown(attempt.getId(), rootMessage(e));
}

And when you go ask the supplier what happened, the answer has three states, not two:

public sealed interface RecoveryResult<O> {
    record Applied<O>(O result) implements RecoveryResult<O> {}
    record ConfirmedNotApplied<O>() implements RecoveryResult<O> {}
    record Indeterminate<O>(String reason) implements RecoveryResult<O> {}
}

A lookup that returns nothing from a lagging read replica is Indeterminate, not "not executed":

NOT_FOUND ≠ CONFIRMED_NOT_EXECUTED

Only ConfirmedNotApplied permits a resend, with the same idempotency key. Indeterminate goes to a human. The test that proves it makes the supplier apply the request, time out, and then lag on lookup; it asserts the supplier received exactly one request.

2. The user approved $300. The agent changed it to $3,000.

The window between "approve" and "execute" is where re-planning happens. The fix is to make the approved thing immutable: a business action is an Intent; once it starts executing its parameters cannot change. A changed parameter is a new Intent with a new approval. This is enforced in PostgreSQL, not in Java:

CREATE TRIGGER trg_intent_immutable BEFORE UPDATE ON intent
    FOR EACH ROW EXECUTE FUNCTION safeexec_intent_immutable();

The test bypasses every Java code path with a raw UPDATE and expects the database to refuse.

Unchanged parameters are not enough either. The approver saw a quote that expires in 30 minutes; the policy that allowed $500 was tightened to $300 an hour later. Same hash, still must not run:

APPROVED ≠ STILL_SAFE_TO_EXECUTE

Execution starts with one transaction: approval valid → evidence within valid_until → content hash of params, facts and evidence values unchanged → current policy re-evaluated → approval atomically bound to the intent → intent OPEN → EXECUTING by compare-and-set → attempt created → commit. Then, and only then, the external call.

3. The supplier processed it. Your process crashed before saving the result.

"Log after success" loses precisely the record you need. The audit is an event stream, one row per stage, each committed in its own transaction. The DISPATCHING event is durable before the external call is made. After a restart, a scanner reclaims stale DISPATCHING rows with FOR UPDATE SKIP LOCKED, marks them UNKNOWN, and runs the same three-state reconcile as in point 1. Two scanner instances never process the same row; the test runs two concurrently over six stale attempts and checks every intent was reconciled exactly once.

The audit table also refuses UPDATE and DELETE via trigger. The test issues them and expects a database error.

4. It passed staging. Would you let it write to production?

Shadow mode: the agent decides on real data, the gateway records what it would have done and executes nothing. A reviewer records what actually happened; the system reports agreement per tool. You release one group of actions at a time when the number is good enough. The kill switch and the policy are never exposed as tools; registering a tool named toggleKillSwitch fails at startup.

And the safety layer must fail closed: a corrupted policy file denies every write instead of allowing everything. There is a test for that too.

5. Something went wrong three days ago. Can you reconstruct it?

If the other four are in place, this is free: GET /api/audit/trace/{traceId} replays REQUEST_RECEIVED, VALIDATED, POLICY_DECIDED, ATTEMPT_CREATED, DISPATCHING, UNKNOWN, RECONCILING, RECONCILED, POLICY_REEVALUATED, ATTEMPT_CREATED, DISPATCHING, SUCCEEDED, with policy version, rule id, intent, attempt and external key on every line. Input is redacted recursively, so supplier.credentials.token never reaches the table.

What this is and is not

It is not an agent framework. It does not plan or manage prompts; it sits between whatever plans (Spring AI, rules, anything) and whatever has side effects. Java 21, Spring Boot 3.5, PostgreSQL 16. The core has no dependency on any model SDK.

The Lite edition (MIT) has the domain model, a minimal validate-and-audit gateway and the six design notes: https://github.com/error0702/safeexec-lite

The full harness — policy engine, intent/attempt idempotency, the recovery scanner, approvals, shadow mode, and the 83 fault-injection tests that run against a real PostgreSQL 16 in Testcontainers — is a paid source kit, $49 until it ships on September 21: https://autorun.fun

If your agent already calls tools in production, I would genuinely like to know how you handle the timeout case. That is the one I keep finding unhandled.