Skip to content
All articles

Engineering

Your AI agent timed out. Did the ERP write still happen?

Inside an invoice write path: evidence binding, approval invalidation, durable commands, and recovery after a lost response. With interactive architecture diagrams and 18 executable failure checks.

The bill exists in the ERP. The agent thinks its request failed. That disagreement is where a routine invoice workflow can turn into a duplicate liability.

A timeout tells the caller that a response did not arrive in time. It does not tell the caller whether the destination committed the transaction. An agent that responds by trying the same business action under a new request identity can make the situation worse.

We built a small, executable reference implementation to examine that boundary. It uses two independent SQLite databases for the command ledger and the ERP store, an approved invoice exception, and deliberately injected failures. This article follows the design, the tradeoffs, and the 18 checks we ran. The lab exercises application controls; it does not connect to a commercial ERP or invoke a model.

The question guiding the work was specific: can we resolve the original operation without creating an unintended second bill?

1. Start with the business invariant

Our starting case has a purchase order for 100 units at $81.00, a receipt for all 100 units, and an invoice for $8,420.00. The quantity matches. The invoice price is $320 above the order. Supplier correspondence could explain the difference, but an explanation is not permission to accept it.

We chose a narrow write boundary: after explicit review, the system may create the approved bill. It may not change vendor banking details, release a payment, or amend the purchase order. Those are different business actions with different permissions and review requirements.

The invariant is more precise than “the agent should be accurate”:

Every new bill must correspond to one approved, unchanged command. An unresolved write must remain visible until its outcome is established.

The first design decision was to separate three planes. Evidence retrieval gathers records. Decision logic prepares and checks a proposal. Execution owns the external mutation and its recovery. The architecture below includes the production responsibilities around the smaller executable write lab.

01
A narrow path to the ERPSystem architecture · arrows show data and command flow
01 / Evidence planeRead access, filtered by identity
DOCUMENTSInvoice + correspondenceSource ID · page reference · digest
SYSTEM OF RECORDPO + receipt + vendorBusiness ID · version · read time
Evidence snapshot
02 / Decision planeApplication authority boundary
MODEL RUNTIMEInterpret → proposeTyped fields with citations
No ERP write credential
APPLICATION CODEValidate → approveAmounts · duplicates · permission
Exact payload + evidence binding
Unresolved evidence or invalid approval → hold
Approved, immutable command
03 / Execution planeOne permitted mutation: create_bill
APPLICATION STORECommand ledger → workerStable operation ID · atomic claim
ERP ADAPTERWrite → reconcileDestination identity · read-back
Audit joins case_id → approval_id → operation_id → ERP record
The write credential belongs to the scoped adapter. The model produces a proposal; the application owns the decision to dispatch it.

This separation also defines the interfaces between people. In an implementation engagement, these decisions need joint ownership rather than an engineering team guessing how accounts payable works:

Working sessionRequired contributorsConcrete decision to record
Invoice exception reviewAccounts payable and finance ownerWhich variances can be accepted, by whom, and for which entity
ERP contract reviewERP administrator and integration engineerSupported bill operation, permissions, idempotency, record lookup, and concurrency behavior
Approval reviewFinance owner, identity owner, and application engineerWhat the reviewer sees, which exact fields are authorized, and when approval expires
Recovery exerciseOperations and engineeringWho owns ambiguous writes, what evidence resolves them, and how new writes can be stopped

The deliverable from those sessions should be a versioned decision record and acceptance cases. A general promise to “keep a human in the loop” leaves too much unspecified.

2. Preserve evidence before asking for judgment

An invoice exception is a join across independently changing sources. The invoice can be re-uploaded, a receipt can be reversed, a vendor can be deactivated, and a purchase order can be amended while a reviewer has the case open.

A production evidence packet should therefore carry the source identifier, retrieved version, read time, and fields actually used. For document extraction, retain page references or bounding boxes alongside the normalized value. For authoritative ERP records, use typed lookups by business identifier. Search is useful for finding correspondence and policy; it should not silently replace an authoritative balance or receipt lookup.

The proposed bill and the supporting evidence have different retention needs. The command must retain its exact executable payload. The evidence record must explain why that payload was proposed. Storing only the model's final paragraph loses the information needed to investigate a dispute.

For the lab, we reduced that evidence to a deliberately small schema:

{
  "entity": "US01",
  "vendor": "V042",
  "invoice": "INV8420",
  "currency": "USD",
  "totalMinor": 842000,
  "poMinor": 810000,
  "received": true,
  "sourceVersion": "po:17/receipt:4",
  "policyVersion": "price:3"
}

The combined version string is a fixture convenience. A real adapter needs individually addressable records and their supported version semantics, including the vendor and any other material inputs. Missing or inaccessible evidence must be an explicit state, not a field the model fills in from context.

Supplier documents are also untrusted input. A sentence asking the system to ignore policy or change bank details is document content, not an instruction that can enlarge the tool's permissions. Retrieval permissions must be enforced before content reaches the model or reviewer. Microsoft’s document-level access guidance is relevant here: identity filtering and permission synchronization are part of the retrieval system, not just the chat interface.

3. Put arithmetic and business identity in code

We represented USD amounts in integer cents. The variance is exactly 842000 - 810000 = 32000 cents. For this configured policy, review is required when the absolute difference exceeds $100 or 2% of the order amount.

const differenceMinor = totalMinor - poMinor;
const requiresReview =
  Math.abs(differenceMinor) > 10000 ||
  Math.abs(differenceMinor) * 100 > poMinor * 2;

This avoids rounding the percentage before comparing it with the threshold. The lab restricts positive values and caps them below the range where these products would lose integer precision. A broader implementation needs explicit rules for credit notes, tax, freight, currency minor units, partial receipts, and line-level rounding. Matching only the invoice grand total is insufficient for those cases.

The variance is approximately 3.95%, so this proposal needs review. The lab requires approval for every supported write; it does not implement an unattended path for smaller variances. Schema-constrained model output can make extraction easier to parse, but valid JSON cannot establish that the extracted amount is correct. Anthropic’s structured output documentation addresses output shape; source validation remains an application responsibility.

We also separated the identities that integrations often accidentally collapse:

IdentityPurposeWhy it is insufficient on its own
Ingestion event IDSuppress repeat delivery of one messageThe same invoice can arrive in another message
Business invoice keyDetect the same payable across deliveriesCorrections and legitimate reference reuse require domain rules
Operation IDIdentify one exact requested mutationA newly generated ID can disguise a repeat of the same business action
Payload digestDetect a change to the approved commandIdentical content can still represent separate legitimate intentions

The lab's business key is the exact tuple (entity, vendor, invoice, currency), stored under a unique database constraint. It intentionally has no fuzzy matching. In deployment, normalization needs finance review: removing punctuation or leading zeros without understanding a supplier's numbering can merge distinct invoices. Historical ERP bills must also participate in duplicate detection; a fresh local ledger cannot see writes performed by another integration.

4. Bind approval to an immutable proposal

A reviewer approves a particular change. That approval should not float forward to whichever payload happens to be in memory when a worker runs.

We bound the lab approval to a SHA-256 digest of a fixed, ordered field list. The approved action is create_bill; the record also includes a reviewer identifier and expiry. Evidence and policy versions are inside the digested payload. Unknown fields are rejected rather than quietly omitted from the executable contract.

The interactive plate below illustrates the binding. Change the amount and the earlier decision no longer matches.

02
Approval is bound to the changeInspect how a payload edit invalidates a decision
REQUESTED MUTATION
Action
create_bill
Supplier
V042 / US01
Amount
$8,420.00
Evidence
PO v17 · receipt v4
Policy
price:3
STORED APPROVALAP reviewerBound to $8,420.00
PO v17 · receipt v4 · price:3
Scoped action + expiry
Digest compared before dispatch
Binding matches → recheck access
A matching digest is one condition. Current authorization, source versions and expiry must also pass.
A digest detects a changed proposal. Authentication, authorization, expiry, and evidence freshness are separate conditions.

For a multi-language system, use a documented canonical serialization rather than assuming every JSON serializer produces identical bytes. A hash is not a signature, and a caller-supplied reviewer name is not authentication. Our lab's approve() method is a trusted fixture helper. A deployed approval service must obtain identity from the authenticated session, enforce role and entity scope, persist the decision in protected storage, and prevent the model or caller from manufacturing an approval record.

Immediately before the first dispatch, the worker checks current permission, expiry, policy version, and source version. The lab tests each failure independently and verifies that zero bills are created. In production, those facts must come from authoritative services; here the test runner supplies them explicitly.

There is a concurrency gap between a fresh read and a remote write. A purchase order can change after revalidation. Where the ERP supports version preconditions, the adapter should use them. Otherwise the design needs a destination-specific reservation, validation hook, or constrained operating process. A local approval digest cannot lock a remote record.

Blocked commands are terminal in this lab. A real reapproval path should preserve the old decision, create a new immutable command revision, and only release a reserved business key when the previous operation is conclusively known not to have applied. Deleting the old row to “try again” destroys the recovery evidence.

5. Persist the command before sending it

The command ledger is the durable record of intent. It stores the operation ID, business key, payload, approval, state, attempt count, and confirmed destination record ID. A unique operation ID prevents duplicate commands; a separate unique business key prevents two commands for the same lab invoice.

Before calling the destination, the worker changes ready to dispatching using a conditional database update:

UPDATE commands
SET state = 'dispatching', attempts = attempts + 1
WHERE id = ? AND state = 'ready';

Only a worker that changes a row may send the command. This gives us a durable dispatch claim. In a scaled system, the surrounding queue needs leasing, backpressure, and operational ownership, but a lease expiry must not be interpreted as proof that a remote mutation failed.

Our two SQLite files use WAL mode and full synchronization. They do not share a transaction. That is intentional: committing a local command and committing an external bill are separate events. The destination-side implementation atomically stores both its operation identity and bill inside its own transaction.

A repeated delivery with the same operation ID and payload returns the existing record. The same operation ID with a different payload is rejected. These are destination capabilities in the lab, not capabilities that a local outbox can impose on an arbitrary ERP.

AWS’s idempotent API design guidance explains why caller-provided request identity, atomic destination handling, and parameter consistency matter. The important adapter questions are the scope of the key, its retention window, and what happens when parameters change. If that window expires while an operation is still unresolved, blindly recycling the key is unsafe.

6. Recover the write whose response vanished

The fault we injected occurs after the destination transaction commits and before the caller receives a successful result. The local ledger moves to unknown; the ERP store already contains one bill.

Select a step or play the sequence to follow both sides of the disagreement.

03
Follow the lost responseInteractive sequence · corresponding fault is exercised in the downloadable lab
Step 1 of 6
Review serviceWrite workerERP store
OPERATION STATEready
ERP BILL COUNT00

op-001 · immutable payload · state: ready

The diagram is a replay of the lab scenario. Its bill counter illustrates state transitions; it is not a live ERP connection.

On the next invocation, the worker sees that the operation has already left ready. It takes the reconciliation path instead of calling create again. The reconciler locates the record by the original operation ID, compares its stored material fields and digest with the immutable command, and records the ERP result.

The result of this injected failure was one bill and one dispatch attempt. We also closed and reopened both databases before recovery. The persisted operation still reconciled to the existing record. That checks durable state across reopening; it is not a power-loss or distributed failover test.

The recovery rules are intentionally conservative:

ObservationLocal resultNew create request
Record found; approved fields matchconfirmedNone
Record found; material fields conflictholdNone
Lookup unavailableunknownNone
Lookup returns no recordunknownNone

An empty search result is particularly easy to overinterpret. Depending on the destination, an accepted job may still be running or the lookup may lag the write. Our lab does not automatically retry unresolved operations, even when its own destination is strongly consistent. It also tests a crash after claiming dispatch but before sending: recovery leaves zero bills and an unresolved command. That is a deliberate availability tradeoff, and it needs an operator resolution path before production use.

Reconciliation is observational. It can establish what already happened after a write approval expires, provided the service still has authorized read access. Expired approval must not authorize another mutation. The distinction prevents recovery from getting stuck merely because the original review window closed.

For a NetSuite adapter, Oracle documents asynchronous REST processing, including job lookup and an idempotency retry mechanism for avoiding duplicate submissions and recovering jobs after connection failure. Verify that contract for the exact operation, account configuration, and supported API path. An accepted asynchronous job is not yet proof of a completed bill, and this SQLite lab does not establish NetSuite compatibility.

7. Keep model orchestration outside the commit boundary

OpenAI and Anthropic are improving the machinery around long-running agents. That changes how the interpretation stage can run; it does not remove the business transaction boundary.

As checked on September 13, 2026, OpenAI’s agent runtime guide distinguishes the managed Agents API, an application-controlled loop through the Agents SDK, and direct Responses API integration. Those are choices about where orchestration lives. We would keep the approved command contract independent of that choice so changing a model runtime does not change what is permitted to reach the ERP.

Anthropic’s Managed Agents permission policies make another useful distinction: policies govern server-executed agent and MCP tools, while custom tools executed by your application remain under your application's control. A platform-level confirmation should therefore not be assumed to satisfy an ERP application's approval rules. Bind the identity, payload, evidence, and policy at the actual execution boundary.

Workflow resumption introduces a related hazard. LangGraph’s interrupt documentation explains that a resumed node starts again and code before the interrupt can execute again. A checkpoint is useful for conversational progress. The command ledger is what identifies the business action across those resumptions.

We did not compare model quality in this lab. A subsequent extraction evaluation should use the same held-out invoices, source access rules, schemas, and task budget for each candidate. Measure field correctness, citation support, abstention, forbidden tool attempts, latency, and cost separately from the write-path invariants.

8. Test resulting records, then design the rollout

A convincing agent transcript can coexist with an incorrect ERP state. We asserted the resulting bill count and operation state, not just the text of a tool response. Anthropic’s agent evaluation guidance similarly distinguishes transcripts from final outcomes and describes combining deterministic and judgment-based evaluation.

The executable suite contains 18 checks:

GroupChecksObserved result
Normal completionApproved write and matching read-back1 bill; confirmed
Ambiguity and recoveryLost response, database reopening, unavailable lookup, crash before send4 checks passed; no second create on recovery
Local identity and approval bindingRepeat command, operation conflict, business-key conflict, edited amount4 checks passed
Permission and freshnessExpiry, revoked access, changed source, changed policy4 checks passed; 0 bills in each
Evidence and arithmeticMissing receipt, exact variance2 checks passed
Destination behaviorParameter conflict, repeated delivery, conflicting record3 checks passed

These are deterministic application tests, not a production success rate or evidence of a particular model's reliability. The suite does not cover identity-provider integration, real network faults, multi-process races, ERP-specific accounting rules, power loss, key-retention expiry, or an operator console. Those belong in the next integration and acceptance stages.

Run the implementation yourself. Download the write lab and test suite into the same directory, then run:

node --test erp-write-lab.test.mjs

We ran it with Node.js 24.14.1 using the built-in SQLite module. No packages, API keys, or external services are required. Node emits an experimental SQLite warning on that version. Each test creates temporary databases and removes them afterward.

For a live engagement, the next step is an ERP sandbox contract test with the actual administrator and finance owner. Reproduce response loss around the real adapter, inspect job and bill records, and prove which observations justify retry or resolution. Exercise partial receipts, supplier aliases, currency behavior, cancellation, concurrent changes, and existing bills created outside this application.

Then run read-only shadow cases alongside the existing process. Reviewers should compare the evidence packet and proposed decision with their own work before enabling writes. The first write rollout should constrain the entity, invoice class, action, and approval role. Operations needs a visible unresolved queue, an owner for each exception, and a control that stops new writes while preserving reconciliation.

Useful production signals include the age and count of unknown operations, approval invalidations, duplicate conflicts, reconciliation duration, and confirmed records lacking audit links. Alert on a write outside its approval boundary as an invariant breach. Set service targets only after observing the real workload and agreeing on operational capacity; an invented accuracy percentage would tell us very little.

We started with one ambiguous API call and ended with a small write path whose behavior can be inspected and reproduced. The broader lesson is that agent quality includes the software around the model: exact decisions, durable intent, constrained execution, and an honest account of what the system knows after failure.

If you are bringing agents into an ERP workflow, talk to Stacklane about the integration boundary. Bring the operation that must be controlled, the records that justify it, and the failure your team needs to recover from.