Documentation / guides
Hooked journals
Observe or fault journal append operations through explicit hooks.
Journal hooks observe the durable append seam without changing the journal
contract. They are middleware around one AppendFunc; they do not observe
live event fan-out, catalog writes, or commands that never reach the journal.
Hook contract
The exact public types are:
type AppendFunc func(context.Context, JournalRecord) (uint64, error)
type AppendMiddleware func(next AppendFunc) AppendFunc
func WithHooks(j SessionJournal, runner *hook.Runner, sessionID uuid.UUID) SessionJournal
func HookMiddleware(runner *hook.Runner, sessionID uuid.UUID) AppendMiddleware
Middleware must call next synchronously exactly once with the supplied record
and return its exact sequence and error. WithHooks returns the original journal
when it is nil or the runner does not handle hook.OperationJournalAppend.
Middleware order
The lifecycle applies opening-fence middleware while the journal is being constructed, then applies ordinary append hooks around later appends. The opening-fence path is intentionally one-shot: it cannot be suppressed, duplicated, or rewritten by a decorator.
%%{init: {"theme":"dark"}}%%
sequenceDiagram
participant R as Runtime
participant W as HookMiddleware
participant J as Raw journal
participant D as Ledger
R->>W: opening fence record
W->>J: next(ctx, exact fence)
J->>D: CAS append fence
D-->>J: sequence or typed error
J-->>W: unchanged result
W-->>R: construction result
R->>W: later event/command record
W->>J: next(ctx, record)
The hook call carries bounded metadata: operation, session coordinates, record
family, record ID, start/end time, and outcome. describeRecord recognizes
event, command, gate-prepared, and fence records. Unknown or panic-prone
metadata is passed through unchanged.
Failure semantics
The wrapper preserves the journal result. A hook start failure delegates
directly. If the delegate returns an error, the hook classifies cancellation or
failure but returns the same error. If the delegate panics, the hook records a
typed appendPanicError result and re-panics; a hook observer panic does not
change the append result. A successful append remains successful even if the
caller context is cancelled after the backend committed.
wrapped := journal.WithHooks(raw, hooks, sessionID)
seq, err := wrapped.Append(ctx, journal.NewEventRecord(ev))
if err != nil {
var appendErr *journal.AppendError
if errors.As(err, &appendErr) {
log.Printf("definite append conflict at %d", appendErr.Expected)
}
return err
}
log.Printf("append sequence=%d", seq)
Hooks are observation, not a retry layer. Retrying in middleware can violate
the exactly-once next rule and can make an ambiguous append impossible to
classify. Let the journal’s idempotent backend handle retry identity.
Wire the journal
Use the checked appender constructors after applying hooks:
raw, err := store.OpenJournal(ctx, id, lease)
if err != nil {
return err
}
observed := journal.WithHooks(raw, runner, id)
events, err := journal.NewJournalEventAppenderChecked(observed)
if err != nil {
return err
}
_ = events
The runtime itself also uses OpenJournalWithOpeningAppend when a hook must
observe the ownership fence. Do not replace the raw journal with an arbitrary
decorator that changes append ordering or hides lease errors.