Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Event envelope

Understand event headers, identity, causality, durability, and validation.

developer

An event has one identity header and one concrete payload. event.MarshalEvent adds the concrete type name and schema version around that value for durable storage. The live subscription does not expose that JSON envelope. It delivers the typed value plus a journal sequence in event.Delivery.

Header and lifecycle mixins

The public header is exact. identity.Coordinates expands to SessionID, LoopID, TurnID, and StepID; identity.Cause expands to causal coordinates plus CommandID, EventID, ToolExecutionID, and Agency.

type Header struct {
	identity.Coordinates
	AgentName identity.AgentName `json:"agent_name,omitzero"`
	EventID uuid.UUID `json:"event_id,omitzero"`
	CreatedAt time.Time `json:"created_at,omitzero"`
	Cause identity.Cause `json:"cause,omitzero"`
	EventVisibility EventVisibility `json:"visibility,omitzero"`
}

func (h Header) EventHeader() Header { return h }
func (h Header) Visibility() EventVisibility { return h.EventVisibility }
func (h Header) ReplyTo() uuid.UUID { return h.Cause.CommandID }

type Class uint8
const (
	Ephemeral Class = iota
	Enduring
)

type Scope uint8
const (
	ScopeSession Scope = iota
	ScopeLoop
)

type EventVisibility uint8
const (
	Public EventVisibility = iota
	Internal
)

type ephemeral struct{}
func (ephemeral) Class() Class { return Ephemeral }
func (ephemeral) EndsTurn() bool { return false }

type enduring struct{}
func (enduring) Class() Class { return Enduring }
func (enduring) EndsTurn() bool { return false }

type terminal struct{}
func (terminal) Class() Class { return Enduring }
func (terminal) EndsTurn() bool { return true }

The mixins are deliberately unexported. Concrete event declarations in pkg/event embed exactly one lifecycle mixin and one scope mixin. A terminal event is Enduring by construction. The header’s zero EventVisibility is Public, which preserves the byte shape of journals written before visibility was added. Internal records persist a non-zero visibility tag.

Identity coordinates

Validation is an identity matrix, not a best-effort convention.

Event shapeRequired coordinatesCoordinates that must be zero
Session-scopedSessionIDLoopID, TurnID, StepID
Loop-scopedSessionID, LoopIDTurnID, StepID
Turn-scopedSessionID, LoopID, TurnIDStepID
Step/tool-scopedSessionID, LoopID, TurnID, StepIDnone
InputCancelledSessionID, LoopID; TurnID is optional; StepID is zeroStepID
Host-owned gateSessionID; inner coordinates are optionalnone, except StepID implies TurnID
Loop-owned gatefull step shapenone

Every event requires a non-zero EventID. Tool interaction and permission review events also require a non-zero ToolExecutionID in their body. A StepID without a TurnID is always invalid. Header.Cause is not copied into the event’s location: it identifies what caused the event, while the embedded coordinates identify what produced it.

Durable JSON boundary

MarshalEvent emits a JSON object with a type discriminator, v: 1, the header fields, and the concrete event fields. UnmarshalEvent reads the discriminator, decodes the known type, projects interface-valued fields, and then validates identity and body. The codec rejects an unsupported schema version and caps the encoded or accepted event at 16 MiB.

ValueDurable behaviorReason
Enduring PublicEncoded, appended, and exposed by ordinary event replayAuthoritative product-visible history
Enduring InternalEncoded and appended through the privileged audit path; filtered from ordinary subscriptions and public replayAudit metadata is not product stream content
Ephemeral PublicLive fan-out only; MarshalEvent returns *EphemeralNotPersistableErrorA later authoritative event reconstructs the state, and some payloads have no codec
Unknown event type or visibilityRejected with a typed errorRestore must fail closed rather than guess

TokenDelta.Chunk is json:"-"; it is a live content.Chunk interface and there is intentionally no durable chunk codec. StepDone.Messages uses the dedicated content message-slice codec. PermissionRequested.Request and GateResolved.Audit are validated and projected through their strict typed codecs. TurnFailed.Err and RestoreErrored.Err are projected as a stable {kind,message} pair; restore returns *RestoredError (or the explicit model-facing restore form), never an arbitrary live error implementation.

Validation errors

Call event.ValidateEvent when a consumer constructs or accepts an event at a boundary. It returns *event.InvalidEventError with the concrete event name, field, and rule. The useful rules are RuleRequired, RuleMustBeZero, RuleInvalid, and RuleUnknownType. MarshalEvent runs both identity and body validation before it emits bytes.

func accept(ev event.Event) error {
	if err := event.ValidateEvent(ev); err != nil {
		var invalid *event.InvalidEventError
		if errors.As(err, &invalid) {
			return fmt.Errorf("reject %s field %s: %s", invalid.Event,
				invalid.Field, invalid.Rule)
		}
		return err
	}
	return nil
}

Other typed codec errors carry the boundary that failed: *EphemeralNotPersistableError, *UnknownEventTypeError, *UnsupportedSchemaError, *EventEncodeError, *EventDecodeError, and *EventLimitError. Do not parse their strings to decide whether restore is safe; use errors.As.

Correlation and visibility

Header.Cause.CommandID is the correlation ID for command outcomes. A Reply event exposes it through ReplyTo(), but it remains in the normal event stream. Cause.EventID and Cause.ToolExecutionID identify event or tool causes when the producer sets them. Cause.Agency records machine or user agency and is not a replacement for a gate’s decision.

Public is the zero value. Internal is used by the session-scoped HustleStarted, HustleCompleted, and HustleFailed audit events and the loop-scoped permission-review events. The hub’s ordinary publication path rejects Internal events with *hub.PublishBoundaryError{Reason: PublishBoundaryVisibility}. The privileged PublishInternalEventChecked path accepts only the recognized internal audit types, only Enduring class, the session’s own SessionID, and a valid event body.

Consumer view

Consumers should use event.EventHeader() instead of asserting a concrete header layout. This keeps correlation code useful across session, loop, turn, and step families.

func correlation(delivery event.Delivery) (uuid.UUID, event.Scope) {
	h := delivery.Event.EventHeader()
	return h.Cause.CommandID, delivery.Event.Scope()
}

func durableID(delivery event.Delivery) string {
	if delivery.JournalSeq == 0 {
		return "live-only"
	}
	return strconv.FormatUint(delivery.JournalSeq, 10)
}

JournalSeq belongs only to live delivery. It is not serialized inside the event JSON, so replay obtains the sequence from the journal cursor and the event bytes remain stable. The sessionstore journal opens with a lease fence, appends Enduring records under a CAS tip, deduplicates identical idempotency IDs, and replays public events in ledger order.

Source and proofs

Continue with filtering and subscriptions to choose a stream or session lifecycle events to interpret restore boundaries.

← back to documentation