Causation
Every dispatched event in Nexus carries a Causation block that records its
provenance: the parent event that caused it, the session it belongs to, the
agent that produced it, and a monotonic per-session sequence number. The bus
populates these fields automatically — plugin authors don’t have to remember
to set them.
Why
Causation is the substrate the Replay primitive walks, the attribution observability collectors filter on, and the dimension the Sub-agent delegation runtime uses to distinguish a parent’s work from a specialist’s. Once it’s present on every event, you can:
- Walk an entire session’s causation DAG to debug what happened.
- Filter envelopes by
AgentIDto inspect just one specialist’s work. - Branch and fork: replay to a sequence, then continue along a different path.
Schema
type EventCausation struct {
ParentID string // EventID of the event whose handler emitted this one
ParentSeq uint64 // Mirrors ParentID via the per-session monotonic sequence
SessionID string // Session this event belongs to
AgentID string // Agent that produced it (sub-agent identity for delegate work)
Sequence uint64 // Monotonic per session; assigned at dispatch
Depth int // Sub-agent recursion depth at emission time
}
Causation lives on both Event[T] and EventMeta. Wildcard subscribers
and filters see it without unwrapping the payload.
How it’s filled
Three sources, in priority order:
- Caller-set fields win. Replay tools and sub-agent runtimes that need
to override the auto-derived values do so by populating
Causationon theEvent[any]they pass toEmitEvent. The bus respects any non-zero / non-empty field. - Dispatch stack supplies
ParentIDandParentSeq. The bus tracks the in-flight event per goroutine; anything emitted inside that goroutine’s handler chain inherits the in-flight event as its parent. - Causation context supplies
SessionID,AgentID,Depth. Two sources here:PushCausationContext(c) func()— per-goroutine stack pushed by callers that have explicit knowledge of who they’re running for (sub-agent dispatch, IO transports binding to a session).SetDefaultCausationContext(c)— bus-wide fallback applied when the calling goroutine has nothing pushed.Engine.StartSessioninstalls theSessionIDhere so every dispatched event carries session attribution even when emitters never callPushCausationContext.
Pushing context
The typical pattern: push at the start of a scoped operation, defer the pop.
if cc, ok := bus.(engine.CausationController); ok {
pop := cc.PushCausationContext(engine.CausationContext{
AgentID: "delegate/analyst/" + subSessionID,
Depth: parentDepth + 1,
})
defer pop()
}
// Every event emitted from this goroutine until pop() carries the AgentID
// and Depth above.
CausationController is an optional interface — checking the assertion at
call sites keeps embedders using a custom bus implementation untouched.
Journal
The journal.Envelope written by pkg/engine/journal carries Seq,
ParentSeq, ParentID, SessionID, AgentID, and Depth alongside the
payload, so downstream replay (pkg/replay) and projection tools can
reconstruct the full causation DAG without re-deriving anything.
Excluded events
Events on the journal exclusion set (core.tick by default) skip seq
assignment, dispatch-stack tracking, and the replay ring — and therefore
have Sequence = 0 and no ParentSeq / ParentID. They still carry the
default SessionID / AgentID from the causation context so observability
tooling can attribute the heartbeats.