Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Hooked journals

Observe or fault journal append operations through explicit hooks.

developer

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.

Source and proof

← back to documentation