Architecture¶
For contributors, and for anyone deciding whether to trust this in their stack. The saga model explains why; this page explains how.
Modules¶
sagacity-core no Spring dependency at all
├── journal/ SideEffectJournal, JournalEntry, Phase, HashChain
│ InMemorySideEffectJournal, PostgresSideEffectJournal
├── compensation/ CompensationRunner, CompensationRegistry,
│ CompensationContext, CompensationReport
├── approval/ ApprovalStore, ApprovalRequest, ApprovalDecision,
│ InMemoryApprovalStore, PostgresApprovalStore
├── audit/ AuditExporter
└── annotation/ @Compensable, @Compensation, Reversibility
sagacity-spring-ai depends on core + spring-ai-model
├── Sagacity the facade — wrap(), saga(), approve/reject/resumeSaga
├── SagacityToolCallback the decorator that does the journaling
├── CompensationScanner reflection over @Compensable at wrap() time
├── SagaScope thread-bound saga context
└── SagaResult outcome of a saga run
sagacity-spring-boot-starter auto-configuration + REST
sagacity-examples runnable demos, not published
sagacity-coverage aggregate JaCoCo report, not published
The dependency direction is strict and deliberate: core knows nothing about
Spring. Its only third-party surface is javax.sql.DataSource. That is what
makes a LangChain4j adapter possible later without touching the journal or the
compensation runner.
The hot path¶
Everything interesting happens in one method: SagacityToolCallback.call.
ChatClient
└─ ToolCallingManager (Spring AI)
└─ ToolCallback
└─ SagacityToolCallback ← we are here
│
├─ no saga on this thread? → delegate straight through
│
├─ IRREVERSIBLE tool?
│ ├─ journal AWAITING_APPROVAL (holds the input)
│ ├─ store ApprovalRequest with SHA-256(input)
│ ├─ mark the scope awaiting
│ └─ return "[AWAITING_APPROVAL] ..." to the model — tool NOT called
│
├─ journal INTENT ← before execution
├─ delegate.call(input)
├─ journal EXECUTED (result)
└─ on throw: journal FAILED, mark scope failed, rethrow
Then, back in Sagacity.saga(...):
work.get() threw? → compensate
scope marked failed? → compensate ← the swallowed-failure case
scope awaiting approval? → return AWAITING_APPROVAL
otherwise → return COMPLETED
Why decorate ToolCallback and not ToolCallingManager¶
This is the single most important design decision, and it was made by reading Spring AI's source rather than its docs.
DefaultToolCallingManager catches ToolExecutionException and hands it to a
ToolExecutionExceptionProcessor, which converts it into an error message fed
back to the model. The model then usually carries on and may even report success.
A decorator at manager level therefore never sees a tool fail. It sees a
successful round trip containing an error string. A saga built on it would report
COMPLETED while the third tool silently failed and the first two were left live.
Decorating the callback puts Sagacity inside that catch. It sees the raw
exception first, journals FAILED, marks the scope, and rethrows so Spring AI's
normal handling is unchanged. The regression test is
sagaDetectsToolFailureEvenWhenSpringAiSwallowsIt.
If Spring AI later ships a first-class tool-execution hook, that becomes the better integration point.
SagaScope: a ThreadLocal, and its consequences¶
SagaScope binds the active saga id to the current thread. The wrapped callback
reads it to decide whether to journal at all.
This buys a clean API — no context parameter threaded through user tool signatures, and tools stay usable outside sagas. It costs the following, and these are real limits rather than todos:
- Tools must execute on the thread that opened the saga. Async or reactive
tool execution will not see the scope, and those calls are silently not
journaled. Only synchronous
ChatClientflows are supported. - Nested sagas throw.
open()refuses if a saga is already active on the thread, rather than silently nesting journals. close()runs in afinally. A leakedThreadLocalon a pooled request thread would attach the next unrelated request to a finished saga.
If streaming support is ever added, this class is what has to change — probably
to a context propagated through Reactor's Context rather than a ThreadLocal.
CompensationScanner: fail at startup, not at refund time¶
wrap() reflects over the tool bean, finds @Compensable, resolves the method
named by by, and registers it in the CompensationRegistry. A by that names
a nonexistent method throws during wrap().
The alternative — resolving lazily at compensation time — means a typo surfaces during a failure, when a refund needs issuing, which is the worst possible moment. Startup is the right time to learn your undo does not exist.
@Compensation is only a marker; the binding is by name.
Journal writes: why INTENT before execution¶
INTENT chargeCard {"amount":"100"} ← committed before the call
EXECUTED chargeCard ch_1M2n3 ← committed after it returns
A crash between the two leaves an INTENT with no outcome. That is not a
bookkeeping gap — it is the most valuable signal the journal produces, because it
says "an effect may exist whose result nobody knows". Compensation deliberately
does not act on it: a tool that may not have completed should not be blindly
undone. It needs a human.
Compensation only walks EXECUTED entries, in reverse seq order.
Concurrency in PostgresSideEffectJournal¶
Appends to one saga must be totally ordered — the hash chain depends on it.
The obvious approach, SELECT ... FOR UPDATE on the saga's last row, does not
work: it locks nothing when the saga has no rows yet, so concurrent first-appends
all compute seq = 1 and all but one die on the primary key. Under READ
COMMITTED the same happens later, because a transaction blocked on the current
last row still computes its sequence from the snapshot it already read.
The fix is a bounded retry on SQLSTATE 23505 that re-reads the tail each attempt.
The reasoning that matters: a losing append must never be dropped. append()
is called after the side effect has run, so a lost EXECUTED row is an effect
compensation will never undo. Failing the append loudly is acceptable; discarding
it is not.
This was found by an integration test against real Postgres. H2 in PostgreSQL mode does not reproduce it.
Two storage shapes, on purpose¶
side_effect_journal |
sagacity_approval_request |
|
|---|---|---|
| Mutability | append-only | rows deleted when consumed |
| Purpose | evidence | working state |
| Hash-chained | yes | no |
| Survives restart | yes | yes (with a DataSource) |
The approval table is not evidence and does not need to be. What was proposed, who approved it, and what executed all live in the journal, inside the chain. The request table only carries the pending payload and its hash so a resume can be verified.
That is also why approve() does not remove the request: resumeSaga still
needs the hash. Which in turn is why resumeSaga checks the journal for an
APPROVED entry rather than trusting the store — store state looks identical
before and after approval.
Design principles¶
Fail closed on the security path. Every check in resumeSaga — request
exists, approval journaled, payload hash matches — refuses on doubt. An approval
with no recorded hash is rejected rather than trusted, because an approval that
never recorded what it approved cannot be shown to match.
Record intent before acting. Applies to the journal and to approvals. The system should always be able to say what it was about to do, even after dying.
The undo is domain knowledge, so the developer declares it. Sagacity does not try to infer how to reverse an effect. Inferring a refund from a charge is guesswork, and guessing wrong moves money.
Partial cleanup beats none. A failing compensation is journaled and the run
continues to the next one. The saga ends COMPENSATION_FAILED and the journal
records exactly what is still dirty.
Evidence, not prevention. The hash chain detects edits; it does not stop them. Claiming more would be dishonest — see the threat model.
Be a decorator, not a framework. No scheduler, no queue, no state machine
beyond the journal, no lifecycle ownership. Sagacity should be removable by
deleting one wrap() call.
Where to look first¶
| Question | File |
|---|---|
| How does journaling attach to a tool call? | SagacityToolCallback.call |
| How does a saga decide it failed? | Sagacity.saga and SagaScope |
| What runs on failure? | CompensationRunner.compensate |
| How is an approval verified? | Sagacity.resumeSaga |
| How is a hash computed? | HashChain.computeHash |
| What does auto-configuration decide? | SagacityAutoConfiguration |
The tests are the other half of the documentation, particularly
StaleApprovalTest (the approval security properties) and
PostgresSideEffectJournalIT (the guarantees only real Postgres can demonstrate).