Approval gates for irreversible tools¶
Some actions have no undo. A wire transfer settles; a deletion is permanent; an email is read. For those, compensation is the wrong tool — the right one is to stop before executing and ask a person.
Mark the tool irreversible¶
@Tool(description = "Send a wire transfer")
@Compensable(reversibility = Reversibility.IRREVERSIBLE)
public String sendWireTransfer(String amount, String to) {
return bank.wire(to, amount);
}
No by = "..." is needed. IRREVERSIBLE is the one case where @Compensable
declares no compensation, because there isn't one.
The three levels:
| Reversibility | Meaning | Behaviour |
|---|---|---|
REVERSIBLE |
perfect undo exists | compensates on failure |
COMPENSATABLE (default) |
imperfect undo — a correction, not an erasure | compensates on failure |
IRREVERSIBLE |
no undo | suspends the saga for human approval |
What happens at runtime¶
When the agent calls an IRREVERSIBLE tool inside a saga, the tool does not
execute. Instead Sagacity:
- journals an
AWAITING_APPROVALentry holding the exact input payload - hashes that payload (SHA-256) and stores it on the approval request
- returns
[AWAITING_APPROVAL] Tool 'sendWireTransfer' requires human approvalto the model - returns
SagaResult.status() == AWAITING_APPROVALfromsaga(...)
SagaResult<?> result = sagacity.saga("transfer-77", () -> agent.run());
if (result.status() == SagaStatus.AWAITING_APPROVAL) {
log.info("waiting on human for {}", result.awaitingToolName());
}
The three-step flow¶
Approving is not executing. These are deliberately separate calls:
sequenceDiagram
participant A as Agent
participant S as Sagacity
participant H as Human
A->>S: sendWireTransfer({"amount":"100","to":"alice"})
S->>S: journal AWAITING_APPROVAL + hash payload
S-->>A: [AWAITING_APPROVAL]
H->>S: GET /sagacity/approvals
H->>S: POST /sagacity/approve/transfer-77/3
S->>S: journal APPROVED (approver identity)
H->>S: POST /sagacity/resume/transfer-77/3 {payload}
S->>S: verify approval exists + payload hash matches
S->>S: execute, journal INTENT then EXECUTED
# 1. See what is pending
curl localhost:8080/sagacity/approvals
# 2. Record the human decision
curl -X POST localhost:8080/sagacity/approve/transfer-77/3 \
-H 'Content-Type: application/json' \
-d '{"approver":"manager@company.com"}'
# 3. Execute, bound to the payload that was approved
curl -X POST localhost:8080/sagacity/resume/transfer-77/3 \
-H 'Content-Type: application/json' \
-d '{"payload":"{\"amount\":\"100\",\"to\":\"alice\"}"}'
Or from Java:
sagacity.approve("transfer-77", seq, "manager@company.com");
SagaResult<String> r = sagacity.resumeSaga("transfer-77", seq, livePayload);
Why approve and resume are separate¶
This is the part that matters, and it is not incidental design.
A gate that only checks "did someone approve saga X step 3?" is defeated by stale approval: the human approves a $100 transfer to Alice, the model re-plans between approval and execution, and a $999,999 transfer to Mallory runs under the same approval. The approver saw one thing; another thing happened.
Sagacity binds the approval to the payload. At request time it stores
SHA-256(input). At execution time it hashes the live payload and compares.
// Approved for this:
{"amount":"100","to":"alice"}
// Resumed with this:
{"amount":"999999","to":"mallory"}
// → REJECTED, journaled, prior steps compensated, tool never called
Verification is exact and byte-level. Even reformatting is rejected:
{"amount":"100","to":"alice"} // approved
{ "amount": "100", "to": "alice" } // rejected — whitespace differs
That is intentional. A hash cannot tell a cosmetic reformat from a meaningful edit, so it refuses both. Pass through the payload you were given, unmodified.
resume enforces three conditions, and fails closed on each:
| Condition | Failure |
|---|---|
| A pending approval request exists | 404, IllegalStateException |
An APPROVED decision was journaled for it |
409 — "no human approval recorded" |
| The live payload hash matches the approved one | 409 — "payload changed since approval" |
An approval request carrying no payload hash is refused too. An approval that never recorded what was approved cannot be shown to match, so it is rejected rather than waved through.
A pending request is not an approval
approve() deliberately leaves the request in the store so resume can
still verify the payload. Store state therefore looks identical before and
after approval — which is exactly why resume checks the journal for an
APPROVED entry rather than trusting the store.
Rejecting¶
curl -X POST localhost:8080/sagacity/reject/transfer-77/3 \
-H 'Content-Type: application/json' \
-d '{"approver":"manager@company.com"}'
Rejection journals the decision and compensates every step that already ran. The pending request is dropped, so it cannot be resumed afterward.
What ends up in the journal¶
seq 1 reserveInventory EXECUTED {"sku":"SKU-9"}
seq 2 sendWireTransfer AWAITING_APPROVAL {"amount":"100","to":"alice"}
seq 3 approval-gate APPROVED approver=manager@company.com
seq 4 sendWireTransfer INTENT {"amount":"100","to":"alice"}
seq 5 sendWireTransfer EXECUTED transfer-ok
Everything a reviewer needs is on the chain: what was proposed, who approved it,
and that the thing executed was the thing approved. The APPROVED decision is
read back from the journal, not a side table, so the record that gates
execution is covered by the same tamper evidence as everything else.
Limitations to know about¶
- The approver identity is whatever the caller says it is. Sagacity records
{"approver":"..."}verbatim and does not authenticate it. Put the endpoints behind your own auth and pass the authenticated principal — see the production checklist. - No approval expiry. A request pending for a week is still approvable. If
that matters, check the
AWAITING_APPROVALentry's timestamp yourself. - Pending requests need a
DataSourceto survive a restart. With one, they are stored insagacity_approval_requestautomatically; without one they are in memory and a deploy strands them. - No policy versioning. The journal does not record which approval policy was in force at decision time.