A spent approval turns a provider error into a record, not a retry
An assistant proposes an outbound action, a human approves it, and an executor sends it. The failure worth studying is not the refusal before the send, it is the provider error that lands after the approval has already been marked consumed. Nothing is left to roll back at that point, and the executor has to decide whether the caller may retry on that error. In exactsend the answer is no: the error gets written down against the execution, the approval stays spent, and a retry needs a fresh decision from a human.
The reason is the one that makes payment integrations awkward, and we have been living with it on card rails and account-to-account flows for eight years in production: a network call is not a transaction. The provider may have accepted the bytes and then failed to tell you so. A timeout is not evidence of non-delivery. If the executor reads a thrown error as "nothing happened," it is guessing on behalf of a human who approved exactly one send.
The transaction ends before the provider is called#
The store port makes the boundary explicit. One method does the only thing that has to be atomic, and its contract sits right in the interface:
export interface ApprovalStore {
recordApproval(approval: Approval): Promise<ApprovalRecord>;
getApproval(approvalId: string): Promise<ApprovalRecord>;
// One step: the approval is marked consumed only where it was not consumed, and the
// dispatching execution row is written with it. A null return means no row changed, so
// the approval was already spent and nothing may be sent.
consumeAndRecordIntent(
approvalId: string,
executionKey: string,
now: number,
): Promise<ExecutionRecord | null>;
recordDispatchResult(
executionKey: string,
result: DispatchResult,
): Promise<ExecutionRecord>;
getExecution(executionKey: string): Promise<ExecutionRecord>;
executionsFor(approvalId: string): Promise<readonly ExecutionRecord[]>;
}Everything before that call is cheap to refuse, so the executor front-loads its refusals. It loads the ApprovalRecord, resolves the revision the approver actually looked at, resolves the adapter, reads the clock once, and only then decides whether it may spend anything:
async execute(approvalId: string): Promise<ExecutionRecord> {
const approval = await this.#store.getApproval(approvalId);
const proposal = this.#proposals.get(approval.proposalId);
const revision = findRevision(proposal, approval.revision);
const adapter = this.#registry.adapterFor(proposal.type);
const now = this.#now();
if (now > approval.expiresAt) {
throw new ExecuteError(
'approval-expired',
approvalId,
`this approval expired at ${String(approval.expiresAt)}, so it decides nothing now`,
);
}
if (now < revision.window.notBefore || now > revision.window.notAfter) {
throw new ExecuteError(
'outside-window',
approvalId,
'the clock is outside the window the approver saw on this revision',
);
}That single clock read matters. Expiry and window are evaluated against the same instant, so an action cannot pass the TTL check and then fail the window check because milliseconds went by in between. Those two refusals, plus unknown-revision from findRevision and unknown-action-type from the registry, all land before any write. The test suite pins the consequence instead of the message: on an expired approval, consumedAt is still null and executionsFor comes back empty. Nothing was bought, so nothing was spent.
After the write, the error is data#
The purchase itself is one call, with a freshly generated execution key. A null return means some other run already spent it:
const intent = await this.#store.consumeAndRecordIntent(
approvalId,
crypto.randomUUID(),
now,
);
if (intent === null) {
throw new ExecuteError(
'already-consumed',
approvalId,
'an execution already consumed this approval, so nothing was sent',
);
}From here the approval is gone whatever happens next, and the code shifts register. The provider call is wrapped, and a thrown error turns into a value:
let result: DispatchResult;
try {
const sent: unknown = await adapter.send({
type: proposal.type,
target: proposal.target,
digest: revision.digest,
executionKey: intent.executionKey,
payload: revision.payload,
attachments: revision.attachments,
});
result = {
state: 'sent',
settledAt: this.#now(),
providerRef: typeof sent === 'string' ? sent : null,
};
} catch (error) {
result = {
state: 'failed',
settledAt: this.#now(),
failure: error instanceof Error ? error.message : String(error),
};
}
return await this.#store.recordDispatchResult(intent.executionKey, result);
}So one method carries two error vocabularies, split by the store write. Before it, failures are ExecuteError, because the caller asked for something that was never going to happen and throwing is the honest answer. After it, failures are an ExecutionRecord with state: 'failed' and the provider's message in failure. The signature says Promise<ExecutionRecord>, and a failed send satisfies it.
That asymmetry is the whole design. An exception invites a retry, since it reads as "this did not work, try again." A record invites a question: did the provider accept those bytes before it broke? The executor does not know, and neither does the caller, so the type system stops offering the comfortable answer. Note also that the clock gets read a second time for settledAt, so the row carries when the attempt actually finished rather than when the decision to attempt it was made.
dispatching is the state that says only what is known#
Three states, and the first one is not a transient implementation artifact:
export type ExecutionState = 'dispatching' | 'sent' | 'failed';
export interface ExecutionRecord {
readonly executionKey: string;
readonly approvalId: string;
readonly digest: string;
readonly state: ExecutionState;
readonly startedAt: number;
readonly settledAt: number | null;
readonly providerRef: string | null;
readonly failure: string | null;
}consumeAndRecordIntent writes the row as dispatching with settledAt: null; recordDispatchResult overlays the outcome on top of the existing row. A crash between those two writes leaves dispatching behind forever, and that is correct. The bytes may or may not have reached the provider. Any state the executor could write at that point would be a claim it cannot support.
The ordering is not left to inspection. One test reaches into the store from inside the adapter, at the only moment when the intermediate state is observable:
test('the dispatching row is committed before the provider is called', async () => {
const seen: ExecutionRecord[] = [];
let peek: () => Promise<readonly ExecutionRecord[]> = async () => [];
const h = await harness({
send: async () => {
seen.push(...(await peek()));
return 'provider-ref-1';
},
});
peek = () => h.store.executionsFor(h.approval.approvalId);
await h.executor.execute(h.approval.approvalId);
assert.equal(seen.length, 1);
assert.equal(seen[0]?.state, 'dispatching');
assert.equal(seen[0]?.settledAt, null);
});If that assertion ever flips, the library has become one that can send without a durable record of having tried. A sibling test runs two executes concurrently and asserts exactly one sent result, one rejection with code already-consumed, one call into the adapter, and one row in the store. Single-use is a property of the store write, not of polite callers.
Why the executor does not retry, even with an idempotency key#
adapter.send receives executionKey, which is exactly the handle a provider needs to deduplicate. It is tempting to conclude a retry is therefore safe: send again with the same key, let the provider collapse the duplicate.
The key makes a retry safe for the provider. It does not make it legitimate for the approver. A second pass over the same approval would have to find it unconsumed, and it is not, because the first pass spent it, and that spending is the record of a human having authorised one send of one digest inside one window. Reusing it would mean the system decided, on its own, that the human would have said yes again. For an irreversible action, that is the one decision the library exists to refuse.
So there are two ways forward from a failed or stranded dispatching row, and both involve someone outside the executor. Either reconciliation asks the provider what actually happened, which the README is honest about not shipping in this phase, or a human looks at the same proposal revision, sees the same digest, and spends a new approval. The reversible flag on each registered action type is there for the former: it says whether a sent action can be taken back, which is what tells later phases how hard reconciliation has to work before a retry is allowed.
What this costs to run#
It costs operational attention. Rows stuck in dispatching do not drain themselves, so something has to own the queue of unresolved executions: a dashboard, an alert on age, a person on call. We would rather carry that cost than the alternative, a system that quietly sends twice and hears about it from a customer.
It costs latency on the unhappy path. A failed send on an irreversible action does not recover in milliseconds, it recovers when a human approves again. That is the correct trade for an action with money or a counterparty on the other end, and the wrong trade for a cache warm-up, which is an argument for not registering cache warm-ups as approved action types.
And failure is a message string, not a typed error. It is written for the operator reading the row, not for code branching on it. If a caller wants to tell a provider's rate limit apart from a malformed recipient, that classification probably belongs in the adapter, where the provider's own error shape is still intact.
The narrow claim the executor makes is the one it can defend: an approval was spent at a known instant, under a known digest, with a known execution key, and here is everything we observed afterwards. Where observation stopped, the row stops too.