Skip to content

Production checklist

Sagacity 0.1.0 sits in the path of real side effects. Work through this before pointing it at anything that moves money.

Must do

Secure the REST endpoints

The approval endpoints are unauthenticated by default. Anything that can reach /sagacity/** can approve a wire transfer.

http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/sagacity/approve/**", "/sagacity/reject/**",
                         "/sagacity/resume/**").hasRole("APPROVER")
        .requestMatchers("/sagacity/audit/**").hasRole("AUDITOR")
        .requestMatchers("/sagacity/approvals/**").hasRole("APPROVER"));

Or turn them off and drive approvals through your own UI:

sagacity:
  approval-endpoints-enabled: false

Pass a real approver identity

Sagacity records {"approver":"..."} verbatim and does not verify it. A caller can claim to be anyone. Pass the authenticated principal from your own security context, never a value the client supplies:

sagacity.approve(sagaId, seq, authentication.getName());

Without this, the approver field in your audit trail is decorative.

Use the Postgres journal and approval store

Sagacity.create() uses in-memory implementations of both. The journal writes no hash chain, and pending approvals do not survive a restart — a deploy would strand every in-flight irreversible tool, because the request holding the approved payload hash is gone even though the journal still shows AWAITING_APPROVAL.

With the starter, supplying a DataSource selects PostgresSideEffectJournal and PostgresApprovalStore automatically. Confirm at startup that verifyJournal does not report "not hash-chained".

Make compensations idempotent

They may be attempted more than once. See Compensation.

Alert on COMPENSATION_FAILED

This status means an effect happened and could not be undone. It is the one outcome that always needs a human.

if (result.status() == SagaStatus.COMPENSATION_FAILED) {
    pager.alert("saga {} left dirty state", result.sagaId());
}

Alert on unmatched INTENT

An INTENT row with no following EXECUTED or FAILED means the process died mid-tool-call. The effect may or may not have happened, and compensation will not touch it because it never reached EXECUTED. Sweep for these:

SELECT j.saga_id, j.seq, j.tool_name, j.timestamp
FROM side_effect_journal j
WHERE j.phase = 'INTENT'
  AND NOT EXISTS (
      SELECT 1 FROM side_effect_journal l
      WHERE l.saga_id = j.saga_id AND l.seq > j.seq
        AND l.tool_name = j.tool_name
        AND l.phase IN ('EXECUTED','FAILED'))
  AND j.timestamp < now() - interval '10 minutes';

Should do

Anchor the hash chain

Tamper evidence only binds someone who cannot recompute the chain. Anyone with database write access can. Periodically export the head hash per saga and store it somewhere the database operator does not control — object storage with object-lock, a signed commit, or a notary service.

List<JournalEntry> entries = journal.entries(sagaId);
String head = entries.get(entries.size() - 1).hash();
anchorStore.record(sagaId, head, Instant.now());

Restrict database privileges

The journal is append-only by intent, not by permission. Enforce it:

REVOKE UPDATE, DELETE ON side_effect_journal FROM sagacity_app;
GRANT INSERT, SELECT ON side_effect_journal TO sagacity_app;

This is the single highest-value hardening step, and it is one GRANT.

Set a retention policy

Sagacity never deletes rows. Decide how long you keep them, and remember that deleting a saga's early rows truncates its chain.

Use a distinct saga ID per task

Journals are keyed by saga_id. Reusing one across unrelated tasks interleaves their entries and makes compensation walk backward across both.

Know these limits

Limit Consequence
No approval expiry A request pending for weeks is still approvable
Approval requests are not themselves hash-chained The durable record of a decision is the journal, not the request table
No policy version in the journal You cannot prove which rules were in force at decision time
No idempotency key passed to tools Sagacity does not deduplicate at the tool boundary; your tools must
No streaming support Only synchronous ChatClient flows are journaled
Chain is per saga No global ordering across sagas, and tail truncation is undetectable
Approver identity unverified Only as trustworthy as the caller you put in front of it

Verify your setup

@Test
void journalIsHashChainedInThisEnvironment() {
    sagacity.saga("smoke-test", () -> tools.noop());
    var v = sagacity.verifyJournal("smoke-test");
    assertThat(v.message()).doesNotContain("not hash-chained");
    assertThat(v.valid()).isTrue();
}

Run that against staging. It catches the in-memory-journal-by-accident case, which is silent and total.