journal

package
v0.33.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

pkg/journal

pkg/journal is the contract for one session's single serialized durable writer. Every command, every enduring event, and every fence flows through one SessionJournal; the log stays totally-ordered and gap-free, and restore replays it.

This package owns the contractSessionJournal, JournalRecord, EventRecord, CommandRecord, LeaseFence — and the JournalEventAppender adapter that the pkg/hub fan-in depends on. The concrete backend lives in pkg/sessionstore over a storage.Composite, wired at the composition root.

What is journal?

  • SessionJournal — the single serialized writer for one session. One method: Append(ctx, JournalRecord) (seq uint64, err error). ctx bounds the caller's willingness to wait; the implementation carries a per-append deadline independent of ctx so one stuck call cannot wedge the serialized writer forever. Appends are totally ordered: returned sequences are strictly monotonic across calls.
  • JournalRecord — the sealed sum a writer persists: an EventRecord (an Enduring event), a CommandRecord (the intent log), or a LeaseFence (an internal fence). Sealed by the unexported isJournalRecord marker, so the serializer's switch over the sum is exhaustive and a foreign type can never masquerade as a record. IdempotencyID() is the stable per-record id a backend uses as its de-dup key so a redelivered append de-duplicates.
  • JournalEventAppender — adapts a SessionJournal to the narrow "append one Enduring event" seam the pkg/hub fan-in depends on. The hub holds an unexported eventAppender interface; this type satisfies it structurally, so the composition root wires it in via hub.WithAppender without the hub ever importing the journal package (Dependency Inversion). After a successful append it best-effort notifies a catalogUpdater so the replay-free session index stays current.
  • ErrorsNilJournalError, CommandRouteMismatchError, *InvalidRecordError, plus the wrapped backend errors a SessionJournal implementation returns.

How to use

You don't. A consumer doesn't hold a SessionJournal directly; the composition root wires one into the hub:

// internal/sessionruntime (illustrative):
appender := journal.NewJournalEventAppender(storeJournal,
    journal.WithCatalog(catalog),
)
hub := hub.New(sessionID,
    hub.WithAppender(appender),
    hub.WithFactory(factory),
    hub.WithFaultReporter(reporter),
)

A backend implements SessionJournal:

type myJournal struct{ ledger storage.Ledger }

func (j myJournal) Append(ctx context.Context, rec journal.JournalRecord) (uint64, error) {
    payload, id, err := journal.MarshalRecord(rec)  // event/command codec
    if err != nil { return 0, err }
    return j.ledger.Append(ctx, id, payload)
}

The default in-tree implementation is pkg/sessionstore; the sibling looprig/fsstore, looprig/natsstore, and looprig/rclonestore modules provide the storage.Composite backends that pkg/sessionstore runs on top of.

Sibling packages

  • pkg/eventEventRecord wraps an Enduring event; event.MarshalEvent is the strict codec the writer calls.
  • pkg/commandCommandRecord wraps a command.Command plus its dispatch target; command.MarshalCommand is the strict codec.
  • pkg/hub — the fan-in that owns the eventAppender seam; JournalEventAppender satisfies it.
  • pkg/sessionstore — the in-tree SessionJournal implementation over a storage.Composite.
  • github.com/looprig/storageLedger, the append-only, CAS-sequenced primitive a backend wraps.

How it is designed

                  Hub (pkg/hub)
                       │
                       │ AppendEvent (single seam)
                       ▼
            JournalEventAppender (this package)
                       │
                       │ Append (one serialized writer per session)
                       ▼
                SessionJournal
                       │
                       │ MarshalRecord (event/command codec)
                       ▼
                  storage.Ledger  (looprig/storage)
                       │
                       ▼
            totally-ordered, gap-free, de-duped log
                       │
                       ▼
                   restore replays it
One writer, total order

A Session owns exactly one SessionJournal. Every command and every enduring event flows through its single Append; the returned sequences are strictly monotonic, so the log is a total order with no gaps. Restore replays it from sequence zero; foreign-loop backends recover their foreign session ids from it.

Sealed record sum

JournalRecord is sealed by the unexported isJournalRecord marker. Only EventRecord, CommandRecord, and LeaseFence implement it, so the serializer's switch over the sum is exhaustive and a foreign type cannot masquerade as a record. IdempotencyID() is the stable per-record id a backend uses as its de-dup key — an event's EventID, a command's CommandID, or a fence's epoch — so a redelivered append de-duplicates instead of double-writing.

Strict codec, fail-closed on ephemeral

The writer never persists an Ephemeral event. event.MarshalEvent fails closed on one; the EventRecord wrapper does not re-check (the codec is the single validation source). The same single-source pattern applies to commands: command.MarshalCommand is the strict codec the writer calls, and ParseApprovalAction is the single validation source shared across the wire decoder and the session route.

Dependency inversion

The hub holds an unexported eventAppender interface; this package's JournalEventAppender satisfies it structurally. The hub never imports pkg/journal. The composition root wires the concrete adapter; the hub depends only on the one-method behavior.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func MarshalCommandApplicationRecord added in v0.31.0

func MarshalCommandApplicationRecord(rec CommandApplicationRecord) ([]byte, error)

MarshalCommandApplicationRecord encodes the record's correlation as the JSON body the sessionstore envelope carries. It rejects a correlation that fails its own validation, so a malformed prefix is never made durable.

func MarshalGatePreparedRecord

func MarshalGatePreparedRecord(rec GatePreparedRecord) ([]byte, error)

MarshalGatePreparedRecord encodes a GatePreparedRecord into the JSON body that the sessionstore envelope carries: the GatePrepared event (via event.MarshalEvent) and the sealed gate.OpenPayload (via gate.MarshalPayload), both as raw sibling keys. A nil payload is rejected fail-closed — a prepared record without its validation payload is corrupt.

func MarshalLeaseFence

func MarshalLeaseFence(f LeaseFence) ([]byte, error)

MarshalLeaseFence encodes a LeaseFence as its minimal JSON object {"epoch":N}.

func ValidateCommandRecordRoute

func ValidateCommandRecordRoute(record CommandRecord) error

ValidateCommandRecordRoute validates the command's own identity contract, then cross-checks the duplicated live dispatch route for machine NoFold or phased delegate input. A zero record LoopID is accepted only because storage replay cannot reconstruct it.

Types

type AmbiguousAckError

type AmbiguousAckError struct {
	Subject  string
	MsgID    string
	Expected uint64
	Cause    error
}

AmbiguousAckError reports an append whose outcome the backend could not resolve: the persist call was lost or timed out and a bounded retry stayed ambiguous, so the serializer cannot tell whether the record landed. The fence stays unadvanced, so the next Append re-fences on the same tip; the caller decides whether to fail the session or retry later. It carries the record's routing/destination identifier, its idempotency id, the expected sequence the append fenced on, and the underlying cause.

func (*AmbiguousAckError) Error

func (e *AmbiguousAckError) Error() string

func (*AmbiguousAckError) Unwrap

func (e *AmbiguousAckError) Unwrap() error

type AppendError

type AppendError struct {
	Subject  string
	MsgID    string
	Expected uint64
	Cause    error
}

AppendError wraps a definite failure to persist a record to the session log. It carries the record's routing/destination identifier, its idempotency id, and the expected sequence the append was fenced under, and unwraps to the underlying backend error (a context deadline, a fence rejection, a transport error). The fence stays unadvanced when this is returned, so the next Append re-fences on the same tip.

func (*AppendError) Error

func (e *AppendError) Error() string

func (*AppendError) Unwrap

func (e *AppendError) Unwrap() error

type AppendFunc

type AppendFunc func(context.Context, JournalRecord) (uint64, error)

AppendFunc is one journal append operation.

type AppendMiddleware

type AppendMiddleware func(next AppendFunc) AppendFunc

AppendMiddleware decorates one AppendFunc. Implementations must invoke next synchronously exactly once with the supplied record and return its exact sequence and error.

func HookMiddleware

func HookMiddleware(runner *hook.Runner, sessionID uuid.UUID) AppendMiddleware

HookMiddleware observes safe, classifiable journal records with runner. Records whose metadata cannot be derived without panicking bypass observation and delegate unchanged. It is the AppendFunc-shaped form of the same observation WithHooks applies, kept for callers that decorate a single append function (the sessionstore opening-append middleware).

type AppendResult

type AppendResult struct {
	Sequence uint64
	Appended bool
}

AppendResult reports the outcome of an append issued through an IdempotentJournal: the durable sequence the record occupies, and whether this call durably persisted a NEW frame (Appended=true) or deduplicated an identical retry of an already-durable record (Appended=false; Sequence is then the ORIGINAL append's sequence, not a new one).

type AppenderOption

type AppenderOption func(*JournalEventAppender)

AppenderOption configures a JournalEventAppender at construction. Applied in order over a defaults struct (nop catalog), so a later option overrides an earlier one.

func WithCatalog

func WithCatalog(c catalogUpdater) AppenderOption

WithCatalog injects the catalog updater the appender notifies after a successful append (best-effort). A nil updater is ignored (the nop default is kept), so the appender owns its invariant — it never holds a nil catalog and never nil-derefs.

type AroundAppend added in v0.31.0

type AroundAppend func(ctx context.Context, rec JournalRecord, next func(context.Context) error) error

AroundAppend decorates one durable append of ANY result type. next must be invoked exactly once, with the context the delegated call should run under — a decorator may substitute its own (the operation-hook decorator does) or pass the caller's through (the offload-GC admission gate does). The error next returns is the append's error; returning a different one substitutes it, and returning without calling next skips the append entirely.

The result VALUE never appears here, which is the point: an append seam that returns a sequence, an AppendResult, or a CommittedAppendResult is decorated by the same function, so a decorator is written once and cannot be written wrong for one seam and right for another.

type CommandApplicationDecodeError added in v0.31.0

type CommandApplicationDecodeError struct {
	Reason string
	Cause  error
}

CommandApplicationDecodeError wraps a failure to decode CommandApplicationRecord bytes at the untrusted restore boundary: malformed JSON, an unknown field, trailing data, or a correlation that no valid admitted record could have produced. It fails secure — a prefix that cannot be trusted must never be read as "this command was already applied", because that answer suppresses the application entirely.

func (*CommandApplicationDecodeError) Error added in v0.31.0

func (*CommandApplicationDecodeError) Unwrap added in v0.31.0

type CommandApplicationEncodeError added in v0.31.0

type CommandApplicationEncodeError struct{ Cause error }

CommandApplicationEncodeError wraps a failure to marshal a CommandApplicationRecord to JSON.

func (*CommandApplicationEncodeError) Error added in v0.31.0

func (*CommandApplicationEncodeError) Unwrap added in v0.31.0

type CommandApplicationRecord added in v0.31.0

type CommandApplicationRecord struct {
	// contains filtered or unexported fields
}

CommandApplicationRecord is the PRIVATE durable record that correlates a public CommandID with the one RuntimeCommandID it maps to and the lease epoch the application runs under. It is appended BEFORE the command's runtime-visible effect, so a redelivery after a crash finds it and replays the original disposition rather than applying the command twice.

It is not an event and not a command: it is never projected to a public wire body, never replayed as an event, and the EventReplayer never decodes it. Existing Harness command headers keep their UUIDs; this record is the durable bridge from Host's opaque identity to those UUIDs, nothing more.

Its idempotency id is derived from the PUBLIC CommandID, which is what makes duplicate delivery detectable at the append: an idempotent journal reports Appended=false for a byte-identical retry and fails closed with an *IdempotencyCollisionError when the same public id names a DIFFERENT mapping.

func NewCommandApplicationRecord added in v0.31.0

func NewCommandApplicationRecord(app runtimecommand.Application) CommandApplicationRecord

NewCommandApplicationRecord wraps app as the private application-prefix record.

func UnmarshalCommandApplicationRecord added in v0.31.0

func UnmarshalCommandApplicationRecord(data []byte) (CommandApplicationRecord, error)

UnmarshalCommandApplicationRecord decodes bytes produced by MarshalCommandApplicationRecord, failing closed on malformed JSON, an unknown field, trailing bytes, or an invalid correlation.

func (CommandApplicationRecord) Application added in v0.31.0

Application returns the wrapped correlation for the serializer to marshal.

func (CommandApplicationRecord) IdempotencyID added in v0.31.0

func (r CommandApplicationRecord) IdempotencyID() string

IdempotencyID is the namespaced public CommandID.

type CommandRecord

type CommandRecord struct {
	// contains filtered or unexported fields
}

CommandRecord wraps a command.Command targeting a specific loop. Unlike an event, a command does not uniformly carry its routing coordinates (Interrupt and Shutdown carry only a Header; the session dispatches them by other means), so the writer supplies the target sessionID/loopID at construction. Its id is the command's physical CommandRecordID. The serializer encodes the wrapped command via command.MarshalCommand.

func NewCommandRecord

func NewCommandRecord(sessionID, loopID uuid.UUID, cmd command.Command) CommandRecord

NewCommandRecord wraps cmd as the intent-log record targeting loop loopID in session sessionID. The caller is the writer, which knows the dispatch target; the command itself may not carry it.

func (CommandRecord) Command

func (r CommandRecord) Command() command.Command

Command returns the wrapped command for the serializer to marshal.

func (CommandRecord) DeliveryPhase

func (r CommandRecord) DeliveryPhase() command.DelegateDeliveryPhase

DeliveryPhase returns the durable delegate-delivery phase carried by a UserInput, or the zero phase for every other command kind.

func (CommandRecord) IdempotencyID

func (r CommandRecord) IdempotencyID() string

IdempotencyID is the command's physical id rendered canonically.

func (CommandRecord) LogicalCommandID

func (r CommandRecord) LogicalCommandID() uuid.UUID

LogicalCommandID returns the command UUID independent of its durable phase.

func (CommandRecord) LoopID

func (r CommandRecord) LoopID() uuid.UUID

LoopID is the dispatch target the writer recorded for this command — the loop the intent-log entry belongs to. It is the backend-neutral routing coordinate a consumer keys on (replacing the subject a NATS backend derived it into).

func (CommandRecord) NormalizedDeliveryFingerprint

func (r CommandRecord) NormalizedDeliveryFingerprint() (Fingerprint, error)

NormalizedDeliveryFingerprint fingerprints the exact UserInput payload with only DelegateDeliveryPhase cleared. Accepted is json:"-" and therefore does not enter the fingerprint; blocks, route, agency, hand-back, timestamps, and all other durable fields remain part of it.

func (CommandRecord) PhysicalID

func (r CommandRecord) PhysicalID() CommandRecordID

PhysicalID returns the typed idempotency identity used by the journal envelope and backend message id. Keeping phase selection here prevents callers from constructing unsafe ad-hoc string ids.

func (CommandRecord) SessionID

func (r CommandRecord) SessionID() uuid.UUID

SessionID is the session this command was recorded under.

type CommandRecordID

type CommandRecordID struct {
	CommandID uuid.UUID
	Phase     command.DelegateDeliveryPhase
}

CommandRecordID is the physical idempotency identity of one command record. Delivery fallback records retain the logical command UUID while using their phase as a typed suffix, so the intent and fallback frames can both be durable without weakening the journal's ordinary idempotency collision rule.

func (CommandRecordID) String

func (id CommandRecordID) String() string

String returns the canonical physical id. Ordinary and intent commands use the logical UUID unchanged; only fallback_queued has a distinct physical id.

type CommandRouteMismatchError

type CommandRouteMismatchError struct {
	RecordLoopID uuid.UUID
	TargetLoopID uuid.UUID
}

CommandRouteMismatchError reports disagreement between a durable delegate command's embedded target and the live CommandRecord dispatch route.

func (*CommandRouteMismatchError) Error

func (e *CommandRouteMismatchError) Error() string

type CommittedAppendResult added in v0.31.0

type CommittedAppendResult struct {
	AppendResult
	Public CommittedPublicBody
}

CommittedAppendResult is AppendResult widened with the committed public bytes. It embeds AppendResult so every existing Sequence/Appended reading applies unchanged.

type CommittedPublicBody added in v0.31.0

type CommittedPublicBody struct {
	EventID string
	Body    []byte
}

CommittedPublicBody is what a durable journal stored, for one record, on the PUBLIC side of its envelope: the canonical public event id it committed the record under and the exact canonical body bytes it wrote. Both are zero for a private record, for a non-event record, and for a deduplicated retry (which stored nothing). Body is owned by the returned value; the journal must not retain a reference it later mutates.

type CommittedPublicJournal added in v0.31.0

type CommittedPublicJournal interface {
	IdempotentJournal
	// AppendCommitted behaves exactly like AppendIdempotent — same fencing, same
	// dedup, same errors — and additionally reports the public event id and the
	// exact canonical public body it stored for a newly appended PUBLIC event
	// record. A deduplicated retry reports Appended=false with a zero Public: this
	// call stored nothing, so it has no stored bytes of its own to report.
	AppendCommitted(ctx context.Context, rec JournalRecord) (CommittedAppendResult, error)
}

CommittedPublicJournal is the OPTIONAL extension a SessionJournal implementation may satisfy to report the EXACT canonical public bytes it stored for a record. It embeds IdempotentJournal, so a committed-bytes implementation is usable anywhere a plain or idempotent SessionJournal is expected and the existing seams are never weakened.

The reason the bytes are reported rather than re-derived is that a second projection is a second answer. A consumer joining a durable tail to a live stream dedupes on (sequence, event id) and compares bodies; if the live body were re-projected it could differ from the stored one — in key order, in a field a later projector version adds — and the consumer would render two different things for one event without any error anywhere. Only the journal that wrote the bytes can say what they are.

type DeliveryTransitionError

type DeliveryTransitionError struct {
	CommandID uuid.UUID
	Phase     command.DelegateDeliveryPhase
	Reason    string
}

DeliveryTransitionError reports a phased delegate command that violates the logical intent-to-fallback ordering or otherwise cannot participate in the transition index. It carries only bounded identity/category data; payloads are deliberately excluded.

func (*DeliveryTransitionError) Error

func (e *DeliveryTransitionError) Error() string

type EventCursor

type EventCursor interface {
	// Next returns the next event and its sequence, or io.EOF when the cold backlog is
	// exhausted. A decode/read error fails secure: the cursor surfaces the typed error
	// rather than skipping or zero-valuing the record.
	Next(ctx context.Context) (event.Event, uint64, error)
	// Close tears down the reader. Idempotent: a second call is a no-op.
	Close() error
}

EventCursor yields a session's Enduring events in sequence order. Next returns the next decoded event with its sequence, io.EOF once the backlog is drained (cold mode), or a typed error on a malformed/missing/corrupt record. Close releases the underlying reader; it is idempotent and safe to call after an error.

type EventRecord

type EventRecord struct {
	// contains filtered or unexported fields
}

EventRecord wraps an Enduring event.Event as a JournalRecord. The event already carries its producer Coordinates and Scope; its id is the event's EventID. The serializer encodes the wrapped event via event.MarshalEvent (which fails closed on an Ephemeral event); the record never re-encodes.

func NewEventRecord

func NewEventRecord(ev event.Event) EventRecord

NewEventRecord wraps ev for the journal. ev must be an Enduring event; the Ephemeral check is the serializer's (event.MarshalEvent), not this wrapper's.

func (EventRecord) Event

func (r EventRecord) Event() event.Event

Event returns the wrapped event for the serializer to marshal.

func (EventRecord) IdempotencyID

func (r EventRecord) IdempotencyID() string

IdempotencyID is the event's EventID rendered canonically.

type EventReplayer

type EventReplayer interface {
	// Open binds a cursor over the session's events selected by req and positioned at
	// req.From.
	Open(ctx context.Context, req ReplayRequest) (EventCursor, error)
}

EventReplayer is the journal's read side: it opens an ordered cursor over a session's Enduring events. It is the narrow counterpart to SessionJournal (the write side) — a caller that only reads history depends on Open alone. The concrete implementation lives in a backend package (e.g. pkg/sessionstore over storage).

type FenceDecodeError

type FenceDecodeError struct {
	Reason string
	Cause  error
}

FenceDecodeError wraps a failure to decode LeaseFence bytes at the untrusted restore boundary: malformed JSON, a wrong field type, a non-object, or trailing data after the object. The codec fails closed with this typed error so callers inspect the cause via errors.As rather than guessing an epoch.

func (*FenceDecodeError) Error

func (e *FenceDecodeError) Error() string

func (*FenceDecodeError) Unwrap

func (e *FenceDecodeError) Unwrap() error

type FenceEncodeError

type FenceEncodeError struct{ Cause error }

FenceEncodeError wraps a failure to marshal a LeaseFence to JSON. A LeaseFence is a single uint64, so this is effectively unreachable, but the codec returns a typed error rather than dropping the json.Marshal error to satisfy the errors-are-typed contract at the package API.

func (*FenceEncodeError) Error

func (e *FenceEncodeError) Error() string

func (*FenceEncodeError) Unwrap

func (e *FenceEncodeError) Unwrap() error

type FenceRecord

type FenceRecord struct {
	// contains filtered or unexported fields
}

FenceRecord wraps a LeaseFence as a JournalRecord. The fence carries no session id of its own, so the writer supplies the target sessionID at construction; its idempotency id is the epoch.

func NewFenceRecord

func NewFenceRecord(sessionID uuid.UUID, fence LeaseFence) FenceRecord

NewFenceRecord wraps fence as the fence record for session sessionID.

func (FenceRecord) Fence

func (r FenceRecord) Fence() LeaseFence

Fence returns the wrapped LeaseFence for the serializer to marshal.

func (FenceRecord) IdempotencyID

func (r FenceRecord) IdempotencyID() string

IdempotencyID is the epoch rendered as a decimal string.

func (FenceRecord) SessionID

func (r FenceRecord) SessionID() uuid.UUID

SessionID is the session this fence marks a lease handover for.

type Fingerprint

type Fingerprint struct {
	// contains filtered or unexported fields
}

Fingerprint identifies a record's persisted kind and payload bytes — exactly what a backend durably writes — independent of any transient in-memory routing a record wrapper additionally carries (CommandRecord's session/loop dispatch target is never persisted, so it never enters a Fingerprint: a backend derives Fingerprint from the SAME (kind, codec-marshaled body) pair it writes to the log, never from the record's Go value). Two records fingerprint equal if and only if a backend would durably persist byte-identical frames for them.

func NewFingerprint

func NewFingerprint(kind string, body []byte) Fingerprint

NewFingerprint derives the Fingerprint of a record's persisted envelope kind (the backend-neutral kind name, e.g. "event"/"command"/"fence") and its codec-marshaled payload bytes, exactly as a backend encodes them before persisting.

type FollowUnsupportedError

type FollowUnsupportedError struct {
	Stream string
}

FollowUnsupportedError is returned by Open when ReplayRequest.Follow is true and the backend implements only the cold (Follow:false) backlog read. Failing closed with a typed error is preferable to silently behaving as a cold cursor that EOFs at the current tip. It carries the log identifier the replay targeted.

func (*FollowUnsupportedError) Error

func (e *FollowUnsupportedError) Error() string

type GatePreparedDecodeError

type GatePreparedDecodeError struct {
	Stage string // "json", "prepared", or "payload"
	Cause error
}

GatePreparedDecodeError wraps a failure to decode GatePreparedRecord bytes at the untrusted restore boundary: malformed JSON, a malformed embedded event, or a malformed embedded payload. It fails secure rather than skipping or zero-valuing the record.

func (*GatePreparedDecodeError) Error

func (e *GatePreparedDecodeError) Error() string

func (*GatePreparedDecodeError) Unwrap

func (e *GatePreparedDecodeError) Unwrap() error

type GatePreparedEncodeError

type GatePreparedEncodeError struct {
	Stage string // "prepared" or "payload"
	Cause error
}

GatePreparedEncodeError wraps a failure to marshal a GatePreparedRecord to JSON: either the embedded GatePrepared event or the gate.OpenPayload failed its codec.

func (*GatePreparedEncodeError) Error

func (e *GatePreparedEncodeError) Error() string

func (*GatePreparedEncodeError) Unwrap

func (e *GatePreparedEncodeError) Unwrap() error

type GatePreparedRecord

type GatePreparedRecord struct {
	// contains filtered or unexported fields
}

GatePreparedRecord is the PRIVATE durable record for a gate's prepare step. It carries the GatePrepared event (the public envelope stored privately until ActivateGate appends the public GateOpened) PLUS the sealed gate.Payload the resolver needs for response validation and restore — a payload that must NEVER be exposed to SSE/history and must NEVER be appended through NewEventRecord or hub.PublishEvent. Its idempotency id is the prepared event's EventID.

func NewGatePreparedRecord

func NewGatePreparedRecord(prepared event.GatePrepared, payload gate.OpenPayload) GatePreparedRecord

NewGatePreparedRecord wraps the private prepared projection and its typed payload as a single private journal record. The caller must NOT also append the GatePrepared event as a public EventRecord.

func UnmarshalGatePreparedRecord

func UnmarshalGatePreparedRecord(data []byte) (GatePreparedRecord, error)

UnmarshalGatePreparedRecord decodes bytes produced by MarshalGatePreparedRecord back into a GatePreparedRecord. It fails closed with a typed *GatePreparedDecodeError on any malformed input — malformed JSON, a malformed embedded event, or a malformed embedded payload — so restore never silently drops or zero-values a private prepared record.

func (GatePreparedRecord) IdempotencyID

func (r GatePreparedRecord) IdempotencyID() string

IdempotencyID is the prepared event's EventID rendered canonically.

func (GatePreparedRecord) Payload

func (r GatePreparedRecord) Payload() gate.OpenPayload

Payload returns the private typed payload the resolver uses for validation/restore.

func (GatePreparedRecord) Prepared

func (r GatePreparedRecord) Prepared() event.GatePrepared

Prepared returns the private prepared projection for the serializer to marshal.

type IdempotencyCollisionError

type IdempotencyCollisionError struct {
	ID  string
	Seq uint64 // the ledger sequence the ORIGINAL (colliding) record already occupies
}

IdempotencyCollisionError reports that a record's idempotency id already names a durable record in the log with a DIFFERENT persisted kind or payload — a genuine id collision (a bug or a forged retry), never a legitimate duplicate retry (which is always byte-identical to what is already durable). The append fails closed rather than silently accepting a differently-shaped record under a reused id.

func (*IdempotencyCollisionError) Error

func (e *IdempotencyCollisionError) Error() string

type IdempotencyIndex

type IdempotencyIndex struct {
	// contains filtered or unexported fields
}

IdempotencyIndex tracks, for every idempotency id already durable in a session's log, the ledger sequence it occupies and the Fingerprint of what was persisted under it. A backend hydrates one from its full durable ledger (see the sessionstore package) before accepting new appends, then consults and updates it — via Check and Observe — under the same lock that already serializes its writes. It is NOT safe for concurrent use on its own; the caller's append-serializing lock is its only synchronization.

func NewIdempotencyIndex

func NewIdempotencyIndex() *IdempotencyIndex

NewIdempotencyIndex returns an empty index ready for hydration.

func (*IdempotencyIndex) Check

func (idx *IdempotencyIndex) Check(id string, fp Fingerprint) (seq uint64, duplicate bool, err error)

Check consults the index for id against the CANDIDATE fingerprint fp of a record about to be appended:

  • id has never been observed: (0, false, nil) — the caller should proceed to durably append the record as new.
  • id was observed with an IDENTICAL fingerprint: (seq, true, nil) — the caller should report AppendResult{Sequence: seq, Appended: false} WITHOUT appending.
  • id was observed with a DIFFERENT fingerprint: (0, false, *IdempotencyCollisionError) — the caller must fail the append closed.

func (*IdempotencyIndex) Observe

func (idx *IdempotencyIndex) Observe(id string, seq uint64, fp Fingerprint)

Observe records that id occupies seq with fingerprint fp, overwriting any prior entry for id. A backend calls it once per record while hydrating from history, and once more immediately after each new durable append lands.

type IdempotentJournal

type IdempotentJournal interface {
	SessionJournal
	// AppendIdempotent behaves exactly like Append — same fencing, same errors —
	// except a record whose IdempotencyID() already names a durable record with an
	// IDENTICAL persisted kind+payload is detected and reported via
	// AppendResult.Appended=false (carrying the ORIGINAL sequence) rather than
	// durably appended a second time. A record whose id names a durable record with
	// a DIFFERENT persisted kind or payload fails closed with a typed
	// *IdempotencyCollisionError.
	AppendIdempotent(ctx context.Context, rec JournalRecord) (AppendResult, error)
}

IdempotentJournal is the OPTIONAL extension a SessionJournal implementation may satisfy to deduplicate a redelivered append by idempotency id. It embeds SessionJournal so an idempotent implementation is usable anywhere a plain SessionJournal is expected — the existing narrow Append seam is never weakened or replaced. A caller that additionally wants to know whether ITS OWN call produced a new durable frame or deduplicated a retry (e.g. to skip a live broadcast for a duplicate) type-asserts for IdempotentJournal and calls AppendIdempotent instead of Append.

type JournalCommandAppender

type JournalCommandAppender struct {
	// contains filtered or unexported fields
}

JournalCommandAppender adapts a SessionJournal to the narrow "append one command" seam the session depends on for the intent log. The session holds an unexported commandAppender interface (AppendCommand(ctx, CommandRecord) error); this type satisfies it structurally, so the composition root (Phase 10) wires it in without the session importing journal internals beyond the CommandRecord constructor (Dependency Inversion). It carries no state beyond the journal — one method, one responsibility: append a CommandRecord (which the SESSION built with the dispatch target loopID, since a command — Interrupt/Shutdown especially — does not carry its own routing) and return the underlying typed error.

Unlike the event appender, the SESSION treats this seam as AUDIT-ONLY: a non-nil error is logged and the dispatch proceeds (losing a command record must never block the user's action). This façade itself never swallows — it returns the journal's error unchanged so the session owns the log-and-proceed decision.

func NewJournalCommandAppender

func NewJournalCommandAppender(journal SessionJournal) *JournalCommandAppender

NewJournalCommandAppender wraps journal as a command appender. Like the event appender's unchecked form, it does NOT guard against a nil journal — use NewJournalCommandAppenderChecked at the composition root where a wiring bug must fail loud.

func NewJournalCommandAppenderChecked

func NewJournalCommandAppenderChecked(journal SessionJournal) (*JournalCommandAppender, error)

NewJournalCommandAppenderChecked is the fail-loud constructor for the composition root: it returns a typed *NilJournalError if journal is nil rather than deferring the failure to a nil-deref at the first append.

func (*JournalCommandAppender) AppendCommand

func (a *JournalCommandAppender) AppendCommand(ctx context.Context, rec CommandRecord) error

AppendCommand appends one intent-log command record: it calls the journal's Append with the session-built CommandRecord and returns the underlying typed error unchanged (the session logs+proceeds — audit-only — never faulting the session on a command-append failure). The CommandRecord routes to the target loop's command (intent-log) subject and uses the command's CommandID as the Nats-Msg-Id (idempotency). The returned sequence is discarded — the session needs only the success/failure signal.

type JournalEventAppender

type JournalEventAppender struct {
	// contains filtered or unexported fields
}

JournalEventAppender adapts a SessionJournal (the write side) to the narrow "append one Enduring event" seam the session hub depends on. The hub holds an unexported eventAppender interface (AppendEvent(ctx, event.Event) error); this type satisfies it structurally, so the composition root (Phase 10) wires it in via hub.WithAppender without the hub ever importing the journal package (Dependency Inversion). Beyond the journal it holds an optional catalog updater (nop by default): after a successful Append it notifies the catalog best-effort so the replay-free session index stays current. One responsibility: wrap the event in an EventRecord (which self-derives its subject from the event's scope+coordinates and its idempotency id from the EventID), append it, then best-effort index it.

func NewJournalEventAppender

func NewJournalEventAppender(journal SessionJournal, opts ...AppenderOption) *JournalEventAppender

NewJournalEventAppender wraps journal as an event appender. It does NOT guard against a nil journal — use NewJournalEventAppenderChecked at the composition root where a wiring bug must fail loud. This unchecked form exists for call sites that have already validated the journal (and for the structural-satisfaction assertion).

func NewJournalEventAppenderChecked

func NewJournalEventAppenderChecked(journal SessionJournal, opts ...AppenderOption) (*JournalEventAppender, error)

NewJournalEventAppenderChecked is the fail-loud constructor for the composition root: it returns a typed *NilJournalError if journal is nil rather than deferring the failure to a nil-deref at the first append.

func (*JournalEventAppender) AppendEvent

func (a *JournalEventAppender) AppendEvent(ctx context.Context, ev event.Event) (uint64, error)

AppendEvent durably appends one Enduring event: it wraps ev in an EventRecord and calls the journal's Append, returning the underlying typed error unchanged (the hub maps it onto a SessionPersistenceFault — never swallowed). The EventRecord routes a session-scoped event to the session subject and a loop-scoped event to its loop event subject, and uses the event's EventID as the Nats-Msg-Id (idempotency). An Ephemeral event is never appended by the hub; if one were passed, the serializer's event.MarshalEvent fails closed inside Append, so this path stays fail-secure.

It returns the assigned durable journal sequence so the hub can ride it on the LIVE delivery (event.Delivery.JournalSeq) — the sequence NEVER enters the persisted event codec. ONLY after the durable append succeeds does it best-effort notify the catalog (with the same sequence) so the replay-free session index stays current. The catalog update is the soft tail: its error is swallowed inside UpdateOnEvent and cannot change this method's return — the durable append stays strict, the catalog is derivable. On an append failure the catalog is NOT touched (the event did not durably land) and seq 0 is returned alongside the error.

When the underlying journal additionally satisfies IdempotentJournal (the optional dedup seam), a redelivered event whose EventID already names a durable record is detected there and reported as AppendResult.Appended=false; this method then returns the ORIGINAL sequence without re-notifying the catalog — a duplicate was already indexed by its first, genuine append, so republishing it a second time would be redundant. A journal that does not implement the optional interface behaves exactly as before (every successful Append notifies the catalog).

func (*JournalEventAppender) AppendEventCommitted added in v0.31.0

func (a *JournalEventAppender) AppendEventCommitted(ctx context.Context, ev event.Event) (event.AppendCommit, error)

AppendEventCommitted is the committed-result event append seam. It is the single core behind AppendEvent and AppendEventResult, which discard the fields they do not need, so all three share one dedup/catalog decision.

Over a CommittedPublicJournal it returns the winning sequence, whether THIS call committed a new frame, and — for a newly committed PUBLIC ENDURING event — the committed public EventID, the exact stored canonical body, and CoveredThrough equal to that same sequence. Over any other SessionJournal it returns sequence and appended state exactly as before and leaves the three committed-public fields zero: bytes that were never reported back must never be invented here, because the whole point of carrying them is that they are the stored ones.

A deduplicated retry (Appended=false) returns the ORIGINAL sequence and NO body and NO coverage. This call committed nothing, so it may not advertise a watermark its own append did not earn; the original append already delivered its body live, and the durable bytes stay durable at that sequence. (Whether a later public READ can serve them back is a separate question with a size-dependent answer — see the caveat on event.Delivery.PublicBody.)

func (*JournalEventAppender) AppendEventResult

func (a *JournalEventAppender) AppendEventResult(ctx context.Context, ev event.Event) (uint64, bool, error)

AppendEventResult is the result-preserving event append seam used by the Hub trusted publication path. Appended is true only when this call created a new durable frame; an identical idempotent retry returns the original sequence and Appended=false. The legacy AppendEvent method above deliberately discards only this boolean so existing callers retain their API and error behavior; this method in turn discards only the committed public fields AppendEventCommitted adds.

func (*JournalEventAppender) SupportsCommittedPublicBodies added in v0.31.0

func (a *JournalEventAppender) SupportsCommittedPublicBodies() bool

SupportsCommittedPublicBodies reports whether this appender can report the EXACT canonical public bytes a public enduring append stored. It is true only when the underlying SessionJournal implements the optional CommittedPublicJournal seam.

It exists because the capability is a property of the injected JOURNAL, not of the appender type: one *JournalEventAppender always has AppendEventCommitted in its method set, so a type assertion alone cannot tell a committed-bytes appender from a legacy one. A consumer that requires committed bytes (the Host runtime adapter, via the hub's segregated committed-public-event capability) must consult this predicate; a false result means the capability is NOT advertised, not that it failed.

type JournalGateAppender

type JournalGateAppender struct {
	// contains filtered or unexported fields
}

JournalGateAppender adapts a SessionJournal to the session gate directory's strict durable append seam. GatePreparedRecord is appended as a private record; GateOpened and GateResolved are public Enduring events and are wrapped in EventRecord exactly like JournalEventAppender.

func NewJournalGateAppender

func NewJournalGateAppender(journal SessionJournal) *JournalGateAppender

NewJournalGateAppender wraps journal as a gate appender. Like the other unchecked appender constructors, it expects a validated journal; use NewJournalGateAppenderChecked at composition roots.

func NewJournalGateAppenderChecked

func NewJournalGateAppenderChecked(journal SessionJournal) (*JournalGateAppender, error)

NewJournalGateAppenderChecked fails loud on nil journal so composition wiring bugs surface at construction instead of the first gate operation.

func (*JournalGateAppender) AppendGateOpened

func (a *JournalGateAppender) AppendGateOpened(ctx context.Context, ev event.GateOpened) error

func (*JournalGateAppender) AppendGatePrepared

func (a *JournalGateAppender) AppendGatePrepared(ctx context.Context, rec GatePreparedRecord) error

func (*JournalGateAppender) AppendGateResolved

func (a *JournalGateAppender) AppendGateResolved(ctx context.Context, ev event.GateResolved) error

type JournalLeaseLostError

type JournalLeaseLostError struct {
	SessionID uuid.UUID
	Epoch     uint64
}

JournalLeaseLostError reports an Append refused because the journal's ownership lease was lost — released by the holder or overtaken by a higher epoch. Once the lease is gone the journal fails every append fast and never re-fetches or advances its expected sequence: a new owner (higher epoch) has written, or will write, its own LeaseFence, so this stale journal's fence would reject the append anyway. Failing here is the fast-path guard; the backend fence is the hard backstop. It carries the session and the lost lease's epoch and unwraps to a *LeaseLostError for errors.As.

func (*JournalLeaseLostError) Error

func (e *JournalLeaseLostError) Error() string

func (*JournalLeaseLostError) Unwrap

func (e *JournalLeaseLostError) Unwrap() error

type JournalNotReadyError

type JournalNotReadyError struct {
	SessionID uuid.UUID
}

JournalNotReadyError reports an Append attempted before the journal's opening LeaseFence was acknowledged. The journal writes the LeaseFence as its first append and only marks itself ready once it lands; an Append before that fails closed with this typed error rather than racing the fence. It carries the session so a caller can correlate the failure.

func (*JournalNotReadyError) Error

func (e *JournalNotReadyError) Error() string

type JournalRecord

type JournalRecord interface {

	// IdempotencyID is the stable per-record id a backend uses as its de-dup key so
	// a redelivered append de-duplicates: an event's EventID, a command's physical
	// CommandRecordID, or a fence's epoch.
	IdempotencyID() string
	// contains filtered or unexported methods
}

JournalRecord is the sealed sum a session's serialized writer persists: an Enduring event, a command (the intent log), or an internal LeaseFence. It is a marker plus the one backend-neutral fact the writer needs to persist without re-inspecting the payload — the record's idempotency id (the stable per-record id a backend uses to de-duplicate a redelivered append). The concrete payload codec is the existing event/command marshaler; a record only carries the typed payload and exposes how to identify it. How a record is routed/stored is the backend's concern (a storage ledger name, a subject, …), never the record's.

The set is sealed by the unexported isJournalRecord marker: only the wrapper types in this package implement it, so the serializer's switch over the sum is exhaustive and a foreign type can never masquerade as a record.

type JournalRuntimeCommandAppender added in v0.31.0

type JournalRuntimeCommandAppender struct {
	// contains filtered or unexported fields
}

JournalRuntimeCommandAppender adapts an IdempotentJournal to the narrow "append one application prefix" seam the session's runtime-command applier depends on. It is a SEPARATE appender from JournalCommandAppender on purpose: the intent log is audit-only and swallows its failures, while this append is the crash-safety barrier in front of a runtime-visible effect and its failure MUST stop the effect.

func NewJournalRuntimeCommandAppenderChecked added in v0.31.0

func NewJournalRuntimeCommandAppenderChecked(journal SessionJournal) (*JournalRuntimeCommandAppender, error)

NewJournalRuntimeCommandAppenderChecked wraps journal as a runtime-command appender. It fails loud on a nil journal and fails closed on a journal that does not advertise IdempotentJournal.

func (*JournalRuntimeCommandAppender) AppendCommandApplication added in v0.31.0

AppendCommandApplication durably appends app's correlation as a private record and reports whether THIS call made it durable. Appended=false means an identical prefix was already durable — the command was already applied — and Sequence is the ORIGINAL append's sequence. A public CommandID already durable under a DIFFERENT mapping surfaces as *IdempotencyCollisionError.

type Lease

type Lease interface {

	// SessionID is the session this lease grants single-writer ownership of.
	SessionID() uuid.UUID
	// Release relinquishes the lease: stops whatever the provider does to keep the
	// grant live, marks it no longer held (firing Lost), and best-effort clears the
	// entry so a successor can re-acquire.
	//
	// WHAT ENDS A GRANT, IN THIS BUILD. The upstream storage.Leaser contract calls a
	// grant "renewable" and its storetest.TestLeaserLifecycle suite conforms
	// providers that implement renewal and expiry, so an implementation MAY end a
	// grant by expiry or higher-epoch takeover. Neither pinned provider does:
	// memstore is Release-only ("no TTL, no takeover"), and fsstore's grant is an
	// advisory lock the OS drops when the holding fd closes or the process exits. So
	// on the backends this module is built against, Release is the only prompt end to
	// a grant, and an unreleased one is held for as long as its holder lives. Code
	// here must therefore treat loss as something it CAUSES, never as something that
	// will happen on its own. Idempotent.
	Release(ctx context.Context) error
	// contains filtered or unexported methods
}

Lease is the single-writer ownership token for one session's durable log. A SessionJournal depends on it (DIP): the composition root acquires a Lease from a backend and passes it in; the journal stamps the lease's Epoch into its first LeaseFence and refuses to append once the lease is lost. The holder (the composition root), not the journal, calls Release — the journal only reads Epoch and the validity/loss signals (see ownershipToken, the narrower view it depends on).

type LeaseFence

type LeaseFence struct {
	Epoch uint64 `json:"epoch"`
}

LeaseFence is an internal journal record marking a lease-handover boundary: a monotonically increasing Epoch fenced into the stream when ownership of a session's writer lease changes. It is journal-private (not an event or a command); the EventReplayer never decodes it. Codec: MarshalLeaseFence/UnmarshalLeaseFence in record_json.go.

func UnmarshalLeaseFence

func UnmarshalLeaseFence(data []byte) (LeaseFence, error)

UnmarshalLeaseFence decodes bytes produced by MarshalLeaseFence. It fails closed with a *FenceDecodeError on any malformed input — empty bytes, non-object JSON, a non-numeric/negative epoch, an unknown field, or trailing bytes after the object. A negative or non-integer epoch fails because Epoch is a uint64.

type LeaseHeldError

type LeaseHeldError struct {
	SessionID uuid.UUID
	Epoch     uint64
}

LeaseHeldError reports that acquiring a lease lost the single-holder race: the session's lease is currently held by a live holder, or a concurrent acquirer won the race. "Live" is not "unexpired" — no pinned provider expires a grant, so a holder stays live until it releases or its process dies, and retrying this later in the same process will keep failing unless the holder acts. It carries the session and the epoch currently fenced so a caller can log who holds it. It is the expected, non-fatal "someone else owns this session" outcome — the loser must not write to the log.

func (*LeaseHeldError) Error

func (e *LeaseHeldError) Error() string

type LeaseLostError

type LeaseLostError struct {
	SessionID uuid.UUID
	Epoch     uint64
}

LeaseLostError reports an operation attempted on a lease that is no longer held: it was released, its holding process exited (fsstore), or — on a provider that implements one, which neither pinned provider does — it expired or was taken over by a higher epoch. It carries the session and the lease's epoch. The journal returns it (wrapped in a JournalLeaseLostError) when an Append is attempted after the lease is lost.

func (*LeaseLostError) Error

func (e *LeaseLostError) Error() string

type MarshalRecordError

type MarshalRecordError struct {
	Subject string
	Cause   error
}

MarshalRecordError wraps a failure to encode a record's payload before it is persisted. It names the record's routing/destination identifier so a caller can correlate the failure without re-inspecting the payload, and unwraps to the underlying codec error (an *event.EphemeralNotPersistableError, *command.UnknownCommandTypeError, a *FenceEncodeError, …) for errors.As inspection.

func (*MarshalRecordError) Error

func (e *MarshalRecordError) Error() string

func (*MarshalRecordError) Unwrap

func (e *MarshalRecordError) Unwrap() error

type NilJournalError

type NilJournalError struct{}

NilJournalError reports that a JournalEventAppender was constructed over a nil SessionJournal — a composition-wiring bug. The checked constructor fails loud with this typed error rather than letting the nil surface as a panic at the first append.

func (*NilJournalError) Error

func (*NilJournalError) Error() string

type NonIdempotentJournalError added in v0.31.0

type NonIdempotentJournalError struct{}

NonIdempotentJournalError reports that a runtime-command appender was asked to wrap a SessionJournal that cannot deduplicate a redelivered append. It is a CAPABILITY refusal, not a wiring bug: the duplicate-delivery contract is implemented BY the journal's dedup, so a journal without it cannot honor the contract and the seam must decline to advertise it rather than apply an admitted command twice.

func (*NonIdempotentJournalError) Error added in v0.31.0

type RecordCursor

type RecordCursor interface {
	// Next returns the next record and its sequence, or io.EOF when the cold backlog is
	// exhausted. A decode/read error fails secure: the cursor surfaces the typed error
	// rather than skipping or zero-valuing the record.
	Next(ctx context.Context) (JournalRecord, uint64, error)
	// Close tears down the reader. Idempotent: a second call is a no-op.
	Close() error
}

RecordCursor yields a session's journal records in sequence order. Next returns the next decoded JournalRecord (an EventRecord, CommandRecord, or FenceRecord) with its sequence, io.EOF once the cold backlog is drained, or a typed error on a malformed/missing/corrupt record. Close releases the underlying reader; it is idempotent and safe to call after an error. It is the all-records counterpart to EventCursor (which yields events only).

type RecordKindError

type RecordKindError struct {
	Subject string
}

RecordKindError reports a JournalRecord whose concrete type is outside the sealed sum the serializer encodes. It is unreachable for an in-package record (the sum is sealed by the unexported marker); it exists so a serializer's default arm fails closed with a typed error rather than panicking.

func (*RecordKindError) Error

func (e *RecordKindError) Error() string

type RecordReplayer

type RecordReplayer interface {
	// Open binds a cursor over the WHOLE session log (every record kind) positioned at
	// req.From. Only the cold path (Follow:false) need be implemented; Follow:true
	// returns a typed *FollowUnsupportedError, matching EventReplayer.
	Open(ctx context.Context, req ReplayRequest) (RecordCursor, error)
}

RecordReplayer is the journal's FULL read side: it opens an ordered cursor over a session's log and surfaces EVERY record — events, commands, AND fences — in sequence order. It is the data seam the transcript export consumes: the narrower EventReplayer yields enduring events only and therefore DROPS every CommandRecord (the user's gate decisions). Reading the whole log in sequence instead yields events and commands interleaved in append/causal order — the merged stream the transcript builder needs. The concrete implementation lives in a backend package (same inputs as EventReplayer).

type RecordTooLargeError

type RecordTooLargeError struct {
	Subject string
	MsgID   string
	Length  int
	Cause   error
}

RecordTooLargeError reports a record whose marshaled payload exceeded the inline threshold but could NOT be offloaded to the backend's content-addressed blob store (the store was unavailable or the upload failed). The journal fails closed with this typed error rather than silently persisting an over-threshold record. It carries the record's routing/destination identifier, its idempotency id, the payload length, and the underlying offload cause.

func (*RecordTooLargeError) Error

func (e *RecordTooLargeError) Error() string

func (*RecordTooLargeError) Unwrap

func (e *RecordTooLargeError) Unwrap() error

type ReplayRequest

type ReplayRequest struct {
	// SessionID is the session whose log is replayed (required; a zero id yields a
	// setup error rather than a replay over every session).
	SessionID uuid.UUID
	// LoopID, when non-zero, narrows an event replay to that single loop; zero
	// replays the session's events plus every loop's events.
	LoopID uuid.UUID
	// From is where the backlog read begins: Beginning or FromSeq(n).
	From StartPos
	// Follow keeps the cursor live after the backlog drains (tailing new appends). A
	// backend that implements only the cold path returns a typed *FollowUnsupportedError
	// from Open rather than silently behaving as a cold cursor.
	Follow bool
}

ReplayRequest selects which of a session's records to replay and how. Which records (events only, or a single loop's) is derived from SessionID + LoopID; how far back from From; whether to keep tailing from Follow. The concrete filtering is the backend replayer's job — this is the backend-neutral request it honors.

type SessionJournal

type SessionJournal interface {
	// Append serializes rec, persists it under the next expected sequence, and
	// returns the assigned sequence. ctx bounds the caller's willingness to wait; the
	// implementation additionally carries a per-append deadline independent of ctx so
	// one stuck call cannot wedge the serialized writer forever. Appends are totally
	// ordered: the returned sequences are strictly monotonic across calls.
	Append(ctx context.Context, rec JournalRecord) (seq uint64, err error)
}

SessionJournal is the single serialized writer for one session's durable log. Append encodes a JournalRecord's payload, persists it under single-writer fencing, and returns the assigned sequence. It is the only thing that writes a session's log; callers funnel every event, command, and fence through it so the log stays a totally-ordered, gap-free record of the session.

The interface is intentionally narrow (one method): a caller that only needs to persist a record must not depend on any log-management surface. The concrete implementation lives in a backend package (e.g. pkg/sessionstore over storage), wired at the composition root — this package owns only the contract.

func Decorate added in v0.31.0

func Decorate(inner SessionJournal, around AroundAppend) SessionJournal

Decorate wraps inner so every append runs inside around, and returns a journal that advertises EXACTLY the optional contracts inner advertises — no more, and no less.

This selection exists in ONE place on purpose. A decorator that exposes only Append silently demotes an idempotent, committed-bytes journal to a plain one, and the damage is invisible: the hub stops seeing Appended=false and re-broadcasts deduplicated retries, and the committed-public-event capability does not degrade but VANISHES — a Host adapter asks for it, is told no, and the deployment reads as headless with no error raised anywhere. That defect shipped twice, in two sibling decorators applied two lines apart at the same composition-root seam. Both now route through here, so a future seam is added once rather than once per decorator.

Adding a seam means: a case in the type switch, a wrapper type promoting the narrower one, and a row in TestDecoratePreservesOptionalJournalContracts.

func WithHooks

func WithHooks(j SessionJournal, runner *hook.Runner, sessionID uuid.UUID) SessionJournal

WithHooks observes each durable append while preserving the journal's result AND its optional contracts. Observation must not amputate capability: a decorator that exposed only Append would silently demote an idempotent, committed-bytes journal to a plain one for every deployment that configures a journal-append hook — the hub would lose the Appended=false signal and re-broadcast deduplicated retries, and the segregated committed-public-event capability would vanish. Decorate owns that preservation for every decorator in the workspace; this function supplies only the observation.

type StartPos

type StartPos struct {
	// contains filtered or unexported fields
}

StartPos is the closed value type naming where a replay begins: the log beginning (every record) or a specific sequence. It is a value, not an interface, so a caller cannot smuggle a third start mode past a switch; the two constructors Beginning and FromSeq are the only ways to build one, and Seq reads it back.

func Beginning

func Beginning() StartPos

Beginning starts a replay at the log's first record.

func FromSeq

func FromSeq(seq uint64) StartPos

FromSeq starts a replay at sequence seq, inclusive — the dormant-snapshot hook (resume after a snapshot's last applied sequence). A seq of 0 is equivalent to Beginning (there is no sequence 0).

func (StartPos) Seq

func (p StartPos) Seq() uint64

Seq returns the inclusive start sequence, or 0 for Beginning. It is how a backend replayer reads the requested start position off a ReplayRequest.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL