Skip to content

Journal schema

CREATE TABLE IF NOT EXISTS side_effect_journal (
    saga_id     TEXT        NOT NULL,
    seq         BIGINT      NOT NULL,
    tool_name   TEXT        NOT NULL,
    phase       TEXT        NOT NULL,
    input       TEXT        NOT NULL DEFAULT '',
    payload     TEXT        NOT NULL DEFAULT '',
    timestamp   TIMESTAMP   NOT NULL,
    hash        CHAR(64)    NOT NULL,
    PRIMARY KEY (saga_id, seq)
);

CREATE INDEX IF NOT EXISTS idx_journal_saga_id ON side_effect_journal (saga_id);
Column Holds
saga_id Scope of one agent task. Chains are per saga.
seq Strictly increasing within a saga, starting at 1, gap-free.
tool_name Tool that produced the entry, or approval-gate for decisions.
phase See below.
input Tool input JSON, verbatim.
payload Result on EXECUTED, error on FAILED, approver on APPROVED, else empty.
timestamp UTC, microsecond precision.
hash SHA-256 over this entry and the previous hash.

Phases

Phase Written when
INTENT Immediately before a tool executes — "we are about to do this".
EXECUTED The tool returned. payload holds the result.
FAILED The tool threw. Effect state is unknown.
COMPENSATED The declared compensation ran cleanly.
COMPENSATION_FAILED The compensation itself threw — the effect is still live.
AWAITING_APPROVAL An IRREVERSIBLE tool suspended the saga.
APPROVED A human approved. payload is approver=<identity>.
REJECTED A human rejected, or verification refused execution.

INTENT is written before execution deliberately. An INTENT with no following EXECUTED or FAILED is the signal that a process died mid-call and a human needs to determine what actually happened.

Why TIMESTAMP, not TIMESTAMPTZ

Postgres TIMESTAMP stores microseconds. Values are truncated to microseconds before hashing so the hashed value is byte-identical to what is read back. Hashing a nanosecond-precision Instant would break verification for every persisted saga on platforms whose clock is finer than a microsecond. All values are UTC.

The approval request table

CREATE TABLE IF NOT EXISTS sagacity_approval_request (
    saga_id      TEXT        NOT NULL,
    journal_seq  BIGINT      NOT NULL,
    tool_name    TEXT        NOT NULL,
    input        TEXT        NOT NULL DEFAULT '',
    input_hash   CHAR(64)    NOT NULL,
    created_at   TIMESTAMP   NOT NULL,
    PRIMARY KEY (saga_id, journal_seq)
);

Holds approvals awaiting a decision, so they survive a restart. Unlike the journal this table is mutable: rows are deleted once consumed or rejected, and re-requesting the same (saga_id, journal_seq) replaces rather than duplicating.

It is working state, not evidence. The durable record of what was proposed, approved and executed lives in the journal, where the hash chain covers it. input_hash here is what a resume is verified against.

Make it append-only

The table 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;

Concurrency

Appends to one saga are serialized by the (saga_id, seq) primary key plus a bounded retry. SELECT ... FOR UPDATE alone is not sufficient — it locks nothing when the saga has no rows yet, so concurrent first-appends would all compute seq = 1 and all but one would fail. A losing append is retried against the re-read tail rather than dropped, because a dropped EXECUTED row is an effect compensation will never undo.