The statifier-ui trace wire format

Copy Markdown View Source

Version: 1

Status and scope

This document is the normative specification ADR-0005 names: docs/wire-format.md, "the format's normative home." ADR-0005 settled the envelope, the nine trace.* type names, the session.start definition message and its role, the $-tagged JSON value discipline, the integer version field starting at 1, and the must-ignore-unknown rule; it delegated field-by-field schemas to this document. When this document and an implementation disagree, this document is what conformance means - ADR-0005's own words, restated here because it is the rule a second interpreter's author needs first.

Conformance. A conformant producer:

  • MUST emit exactly one session.start message opening a session's stream.
  • MUST, when tracing is enabled, emit the nine trace.* types at the phase boundaries Appendix D names, with the envelope fields this document requires.
  • MAY emit further message families (this document also specifies ten effect.* types and four session.* lifecycle types) and MAY add fields to any message beyond what this document requires.

A conformant consumer:

  • MUST ignore fields it does not recognize on a message of a known type.
  • MUST ignore messages whose type it does not recognize.

Because of the two MUST-ignore rules, adding a field or a type is not a version bump. A version bump is reserved for a change that would make a consumer of the previous version misread the stream - removing a field, changing a field's meaning or shape, or renaming a type.

The envelope

Every message is a JSON object. Every message carries:

FieldTypePresent on
typestringalways
sessionstringalways
seqintegeralways
macrostepintegertrace.* and effect.*
microstepintegertrace.* and effect.*
roundintegertrace.* and effect.*

type is a dotted-namespace string identifying the message shape; the remaining sections of this document define every value it may take. session is the emitting session's id (statifier's _sessionid), so a consumer can share one channel across a session and, later, an invoke tree of related sessions. seq is a per-session monotonic integer, described fully under "Ordering" below.

effect.* messages carry all three counters, round included (sui-67d). The value is the engine's own stamp, carried verbatim: as of statifier ADR-0046 (st-xb2b), every core (non-trace) statifier effect payload carries macrostep, microstep, and round - effects emitted before that fold carry round: 0 - and this producer propagates the stamp rather than inventing one. Between ADR-0046 landing upstream and sui-67d landing here, this producer emitted round on trace.* messages and effect.budget_exhausted only; the other effect.* types gained the key later. Versioning decision, recorded rather than left implicit: adding round to the remaining effect.* envelopes is an additive field change, exactly what the conformance section's MUST-ignore rule makes safe, so the format version stays 1 - the same reasoning effect.datamodel_change records below for adding a whole type. A consumer reading an older recorded stream must still tolerate effect.* messages without round (the must-ignore rule's mirror image: absence of a field a newer producer would have written is not an error).

Beyond the fields in the table, every message carries a payload: the type-specific fields, documented below one type at a time. A payload key never collides with an envelope key - type, session, seq, macrostep, microstep, and round are reserved words in every message's JSON object. This is why effect.invoke and effect.send/effect.send_delayed name their own <invoke>/<send> type/typeexpr attribute invoke_type and send_type rather than the engine's own field name type - the element attribute and the envelope's message-type discriminator are unrelated concepts that would otherwise collide under one key in the same flat JSON object.

Ordering

seq is stamped by the producer at the point a listener subscribes to a session's effect stream - the subscription boundary - starting at 0 on that session's session.start message and incrementing by exactly one for every message the producer emits afterward on that session. No other component may assign seq; a carrier that relays messages onward must preserve the value the producer stamped.

(macrostep, round) is the timeline key: it names when in the run a trace.* message belongs, independent of delivery order. seq totally orders one session's message stream; (macrostep, round) orders the run itself.

Interleaving across sessions is arbitrary. The format guarantees total order within one session and promises nothing about how two sessions' messages interleave on a shared channel - that interleaving is whatever process scheduling produced, not a promise anyone made. A consumer merging multiple sessions onto one timeline sorts by (session, seq), or by (macrostep, round) within a session, and never reads causality from arrival order.

Monotone delivery (st-r6l9, closed by ADR-0044). A live statifier session used to be able to deliver messages whose seq order did not match (macrostep, round) order, and could deliver trace.* messages after session.halted, on charts that exercise <invoke> or an internal <send>. ADR-0044 closed that seam: re-entry effects are now enqueued and FIFO-drained after the outer batch rather than performed inline, so arrival order is non-decreasing in (macrostep, round) across a whole run, and {:halted, reason} is promised as end-of-stream. ADR-0044 also settled the uniqueness key for quiescence: more than one trace.macrostep_stable per macrostep is explicitly allowed, but exactly one per (macrostep, round), with the last-arriving one being that macrostep's quiescence.

One consequence still worth stating for anyone building on this format:

  • Consumers reconstruct the run's timeline by sorting on (macrostep, round) rather than relying on arrival order - that key names when in the run a message belongs, independent of delivery, and it is still the right key to sort by even though arrival order is now guaranteed non-decreasing in it.

A chart that never uses <invoke> or an internal <send> was never exposed to the old seam, and its trace remains byte-comparable run to run, which is what makes the worked example and the golden test at the end of this document possible.

JSON discipline

Four rules make the format precise enough for a byte-comparable golden trace, and precise enough that a second interpreter reproduces the same bytes for the same run.

Value encoding

Anything occupying a value position - _event.data, a <log>'s resolved value, donedata, a <send>'s resolved data, an <invoke>'s resolved params/content - is encoded through the same four-form scheme StatifierUI.Value implements:

  • Booleans, integers, floats, strings, nil, lists, and string- or atom-keyed maps (atom keys serializing as their names) map to their natural JSON form.
  • A Date encodes as a one-key tagged object: {"$date": "2026-08-16"} (ISO 8601).
  • A DateTime encodes the same way: {"$datetime": "2026-08-16T12:00:00Z"} (ISO 8601).
  • A duration encodes as {"$duration": {...}}, an object carrying all eight unit fields - years, months, weeks, days, hours, minutes, seconds, milliseconds - as integers, absent units filled with 0.
  • The :undefined sentinel, wherever it appears inside a composite value (a list element, a map value) rather than at a field's top level, encodes as {"$undefined": true} - absence has no positional spelling inside a list or a map value, so the sentinel needs an explicit encoding there even though it is spelled by key-absence at a message's top level (see "Absence" below).

The one-key $-prefixed object shape is reserved by this document for exactly these four forms. A host value that happens to be a one-key map whose only key starts with $ and is not one of $undefined, $date, $datetime, or $duration is a spec violation on the producer's side, not a value this format can carry unambiguously - accepted as vanishingly rare.

Absence

Key absence and JSON null mean different things, and no layer of this format may collapse them. The rule stated once, generalized to every nullable field this format carries (not only _event.data, though that is where the distinction was first load-bearing):

  • Key absent - the field has no meaningful value at all (statifier's :undefined sentinel, or an optional field the producer was not given).
  • JSON null - the field is present, and its value is null.
  • An empty object or array ({}, []) - the field is present, and its value is an empty collection.

All three are distinct and a consumer must be able to tell them apart. The rule applies uniformly to: _event.data (:undefined -> key absent, nil -> null, %{} -> {}); Event's invokeid, origin, origintype, sendid, and cause; Log's label, c_index, and owner; Trace.Done's donedata; and, the load-bearing case for a consumer reconstructing the run, trace.transitions_selected's event field, whose absence marks an eventless round - the platform selected transitions with no event to match against, spec's NULL round. There is no separate boolean flag for this; the absence of the event key is the signal, the same way absence signals "no data" everywhere else in this rule. A consumer must not confuse "the event key is absent" with "the event key is null" - they never mean the same thing anywhere in this format.

Structural tagging: "kind"

Statifier represents some information as Elixir tuples - the eight variants of an internally-raised event's cause origin, and the five variants of which content block ran. Nothing tuple-shaped crosses the wire. Each variant becomes a JSON object whose "kind" field names the tuple's tag and whose remaining fields name its positional elements:

{:content, c_index, owner}       -> {"kind": "content", "c_index": c_index, "owner": {...}}
{:onentry, state_index, ordinal} -> {"kind": "onentry", "state_index": state_index, "ordinal": ordinal}

and so on for every variant listed under "Origins" and "Owners" below. "kind" rather than "$kind": the $-prefixed one-key shape above is reserved for the value codec's four forms, and these are multi-key structural objects appearing in a known field position, not values sitting in a value slot.

Canonical order

  • Object keys are lexicographic, in every object this format produces, at every nesting level.
  • A genuine set-valued field - a MapSet in the engine - serializes as an array in ascending-index order. This applies to configuration wherever it appears (trace.macrostep_stable, trace.done, effect.budget_exhausted).
  • A sequence-valued field that is already ordered by the engine keeps that order; it is not additionally sorted (ADR-0011). This applies to trace.exit_set and trace.entry_set's indexes fields precisely: they are sequences in the engine's own emission order - exit order is inner-to-outer (the order Appendix D's exitStates actually visits them), entry order is ascending - and re-sorting the exit set would reverse what happened. Both orders are deterministic for a given run, so preserving them costs the byte-comparability rule nothing. Every other sequence field in this document that is not already identified as a genuine set (transition lists, content lists, and so on) likewise keeps the engine's own order.

Together, canonical ordering plus the value encoding above is what makes two producer runs of the same chart, the same fixtures, and the same event script byte-identical JSON Lines - the golden-trace conformance mechanism ADR-0005 names.

session.start

The definition message that opens a session's stream. Every later message's indexes (state indexes, t_index, c_index) resolve back to source locations only through the tables this message carries - a consumer with no compiler of its own can still build a working inspector, because everything it needs to resolve an index arrived once, up front.

FieldTypePresence
versionintegeralways
statesarray of objectsalways
transitionsarray of objectsalways
contentsarray of objectsalways
dataarray of objectsalways
sourcestringpresent only when the host supplies chart source
fixturesobjectpresent only when the host supplies a fixtures bundle
parent_sessionstringpresent only when this session was started by <invoke>
invokeidstringpresent only when this session was started by <invoke>

version is 1 for this document. It is the fixtures sidecar's own version convention (ADR-0003), reused here rather than invented fresh.

data is always present, never key-absent, even when the chart has no <datamodel> at all: an empty datamodel still emits [], the empty-collection arm of the absence rule below, not the omitted-key arm.

states is one object per compiled state, in index order (index 0 is always the synthesized :scxml root):

FieldTypePresence
indexintegeralways
kindstringalways - one of "scxml", "state", "parallel", "final", "history"
idstringomitted when the state has no id (always omitted at index 0)
parentintegeromitted only at index 0, the root
childrenarray of integersalways (empty for an atomic state)
transitionsarray of integersalways - the state's own selectable t_index list
locationlocation objectalways

transitions is one object per compiled transition, in t_index order:

FieldTypePresence
t_indexintegeralways
sourceintegeralways - the owning state's index
targetsarray of integersalways (empty for a targetless transition)
eventsarray of arrays of stringsalways - one dot-split token list per whitespace-separated event descriptor; empty for an eventless transition
typestringalways - "internal" or "external"
contentarray of integersalways - the transition's own executable content, c_index list in document order; empty when the transition carries none
locationlocation objectalways
cond_locationlocation objectpresent only when the transition carries a cond

contents is one object per compiled executable-content node, in c_index order:

FieldTypePresence
c_indexintegeralways
kindstringalways - the node's own tag: "raise", "log", "assign", "if", "foreach", "script", "send", or "cancel"
locationlocation objectalways

Every content node kind except <script> carries a :location field directly. Statifier.Machine.Content.Script is the one exception - it carries :node_location instead, because a script's own compiled program lives where a sibling node's resolved value would. This document's producer reads a content node's location as its :location field when present, falling back to :node_location when it is not, so every entry in contents carries a location regardless of kind. A producer that hardcodes :location and skips the fallback will crash on the first <script> in a chart - this is the specific bug this note exists to prevent.

data is one object per compiled <data> element, in d_index order (document order across the whole chart, not per <datamodel> block):

FieldTypePresence
d_indexintegeralways
idstringalways - SCXML requires id on <data>
locationlocation objectalways - the <data> element's own span
value_locationlocation objectpresent only when the compiler recorded a span for the element's value

This table is deliberately identity only: it resolves a d_index to an id and a source span, and carries no representation of the element's declared value. A consumer wanting to display the declared value reads it out of source at value_location, subject to the fallback below; a consumer wanting a runtime value reads session.datamodel for the starting snapshot and the datamodel-change messages after it. The compiled value itself is an expression program, a compile error, or an unresolved src URI depending on how the element was written, and none of the three has a language-neutral encoding in this format. Adding a value field later would be an additive change and therefore not a version bump (ADR-0005), so this document commits to the narrow shape now rather than to an encoding it would have to keep.

value_location is not always a value span, and a consumer must check before slicing. It spans the element's written value only when the element has one to point at: the expr attribute's value for an expr-written element, the src attribute's value for a src-written one. An element written with neither - a bare <data id="x"/>, or one whose value is child content - has no distinct value span, and value_location falls back to the <data> element's own span, equal to this row's location. So a consumer slicing source at value_location must compare it against location first: when the two are equal there is no value span, and the slice is the whole element (<data id="x"/>), not a value. This is the one place value_location parts company with a transition's cond_location, which is absent when there is no guard rather than falling back, and therefore always spans a guard when present.

A location object is always all six fields, and is either wholly present or wholly absent - there is no partial location:

FieldType
start_lineinteger (1-based)
start_columninteger (1-based, Unicode codepoints)
start_offsetinteger (0-based byte offset)
end_lineinteger (1-based)
end_columninteger (1-based, Unicode codepoints)
end_offsetinteger (0-based byte offset, exclusive)

source, when present, is the chart's SCXML text, verbatim. It cannot be derived from the compiled Machine - the compiled form does not retain source text - so this document's producer accepts it as caller-supplied context rather than recovering it. Absence means the host supplied none.

fixtures, when present, is the ADR-0003 sidecar's decoded JSON object, carried through verbatim, its own version field included. This document's producer does not construct or validate a fixtures bundle; the caller supplies an already-decoded object, or supplies none.

parent_session and invokeid, when present, name the session that started this one via <invoke> and the invokeid this session stamps, respectively. Together they let a consumer holding two sessions' session.start messages reconstruct the parent-child edge between them without any other channel. Both are absent for a session with no invoking parent.

A caveat on location granularity. The tables above are built from the compiled %Statifier.Machine{} layer, which carries element-level spans - a whole <transition>, a whole <assign> - plus a transition's own cond_location for its guard expression specifically and a <data> element's own value_location for its declared value where the element was written with one (see the data table's fallback note above). It does not carry attribute-level spans for every attribute (for example, a <send> element's individual event/target/delay attribute spans): that finer table, attribute_locations, lives one layer up, on the Document the compiler consumes, not on the Machine this document's producer reads (tracked as sui-qay). A consumer wanting hover-precision on an individual attribute rather than the whole element does not yet have it from session.start alone.

The nine trace.* schemas

Every trace.* message carries the envelope's macrostep, microstep, and round, in addition to type, session, and seq. Fields below are the payload beyond the envelope.

trace.event_dequeued

Emitted when an event is selected off a queue for processing.

FieldTypePresence
eventevent objectalways
fromstringalways - "external" or "internal"

An event object is Statifier.Event.t() rendered to JSON:

FieldTypePresence
namestringalways
datavaluepresent only when data was supplied (absence-rule: :undefined omits the key, nil present as null, %{} present as {})
typestringalways - "external", "internal", or "platform"
causecause objectpresent only for "internal"/"platform" events the platform itself raised
invokeidstringpresent only when set
originstringpresent only when set
origintypestringpresent only when set
sendidstringpresent only when set

A cause object (Statifier.Event.Cause.t()):

FieldTypePresence
originorigin objectalways - see "Origins" below
macrostepintegeralways
microstepintegeralways
roundintegeralways

trace.transitions_selected

Emitted whenever transition selection runs, including when it selects nothing.

FieldTypePresence
t_indexesarray of integersalways - selected transitions' t_index, in selection order, empty when none selected
eventevent objectpresent only for an event-triggered round; absent marks an eventless (NULL) round

trace.exit_set

Emitted before any state is exited, whether by ordinary transition or by interpreter shutdown.

FieldTypePresence
indexesarray of integersalways - the states about to be exited, in the engine's own exit order (inner-to-outer), not re-sorted; empty when none exit

trace.content_executed

Emitted when a block of executable content runs (an <onentry>/<onexit> block, a transition's own content, or a top-level <script> at load time).

FieldTypePresence
ownerowner objectalways - see "Owners" below
c_indexesarray of integersalways - the run content nodes' c_index, in execution order

trace.entry_set

Emitted before any state is entered, with compute_entry_set's result.

FieldTypePresence
indexesarray of integersalways - the states about to be entered, in the engine's own entry order, not re-sorted; empty when none enter

trace.macrostep_stable

Emitted once the configuration reaches quiescence - the macrostep's microstep loop has drained.

FieldTypePresence
configurationarray of integersalways - the full configuration (ancestors included), a genuine set, sorted ascending

trace.done

Emitted alongside the top-level final entry / interpreter exit.

FieldTypePresence
donedatavaluepresent only when the top-level final carried <donedata> (absence-rule applies)
configurationarray of integersalways - the full configuration as it stood at exit, a genuine set, sorted ascending

trace.invoke_pass

Emitted once the invoke pass finishes, including when it starts nothing.

FieldTypePresence
state_indexesarray of integersalways - states_to_invoke, in the entry order the pass walked, including a state that owns no <invoke>
invoke_idsarray of stringsalways - every invocation this pass actually started, in the order it started them

trace.finalize_autoforward

Emitted once per external event, at the end of the finalize/autoforward pass, including when nothing was finalized or forwarded.

FieldTypePresence
eventevent objectalways - the external event the pass matched against
finalizedarray of stringsalways - invoke_ids of every invocation <finalize> ran for
forwardedarray of stringsalways - invoke_ids of every invocation event was autoforwarded to, in the pass's own walk order

The ten effect.* schemas

The core (non-trace) statifier effects, mapped one-to-one onto their own namespace: the nine originals plus effect.datamodel_change (Statifier.Effect.DatamodelChange, st-oef3). The engine's eleventh core effect, Statifier.Effect.DatamodelInit, is the one exception to the one-to-one mapping - it serializes as session.datamodel, below. ADR-0005 leaves non-trace effect naming to this document ("their own namespaces as consumers need them"); this document uses one effect.* family rather than several separate top-level namespaces, because a bare top-level done type would sit confusingly next to trace.done - a different message about the same moment in the run. effect.* and trace.* are two visibly distinct halves of the vocabulary: trace.* are the nine Appendix D phase boundaries and effect.* are the core effect vocabulary, both stamped with all three counters (see "The envelope" above; round joined the effect.* envelopes in sui-67d, after effect.budget_exhausted had carried it from the start).

Every effect.* message carries macrostep, microstep, and round.

effect.log

Payload for <log> (spec 4.7).

FieldTypePresence
labelstringpresent only when the element wrote a label
valuevaluepresent only when the element wrote an expr (absence-rule applies)
c_indexintegerpresent only when known
ownerowner objectpresent only when known

effect.done

The terminal effect, emitted once after top-level final entry.

FieldTypePresence
donedatavaluepresent only when the top-level final carried <donedata> (absence-rule applies)
configurationarray of integersalways - the full configuration at exit, sorted ascending

effect.budget_exhausted

Emitted when a macrostep's fold spends its round budget without reaching quiescence (ADR-0019). This was the one core effect that carried round before sui-67d propagated the key onto the rest of the effect.* family - see "The envelope" above.

FieldTypePresence
configurationarray of integersalways - the configuration as the last round left it, sorted ascending
budgetinteger or stringalways - the spent budget; the string "infinity" when the budget was unbounded
pending_internal_eventsarray of event objectsalways - the queue's ordered view at exhaustion

effect.invoke

Emitted once per <invoke> the invoke pass actually started (spec 6.4).

FieldTypePresence
invoke_idstringalways
invoke_typestringpresent only when set - the <invoke> element's own type/typeexpr attribute
srcstringpresent only when set
paramsvaluepresent only when the invocation resolved params (absence-rule applies)
contentvaluepresent only when the invocation resolved content (absence-rule applies)
autoforwardbooleanpresent only when set
state_indexintegeralways - the invoking state
invoke_indexintegeralways - the invocation's position in the state's own invoke list

effect.cancel_invoke

Emitted once per invocation the engine cancels because its owning state exited while the invocation was still live.

FieldTypePresence
invoke_idstringalways
state_indexintegeralways - the state that owned the cancelled invocation

effect.autoforward

Emitted once per autoforwarding invocation, during the finalize/autoforward pass.

FieldTypePresence
invoke_idstringalways
state_indexintegeralways - the invoking state
eventevent objectalways - the external event forwarded verbatim

effect.send

Payload for <send>, fired immediately (spec 6.2).

FieldTypePresence
eventstringalways
targetstringpresent only when set
send_typestringpresent only when set - the <send> element's own type/typeexpr attribute
datavaluepresent only when resolved (absence-rule applies)
send_idstringalways
id_from_authorbooleanalways - whether the document wrote id/idlocation itself, rather than the engine generating one
c_indexintegerpresent only when known
ownerowner objectpresent only when known

effect.send_delayed

Payload for <send> with a delay/delayexpr. Carries every field effect.send does, plus:

FieldTypePresence
delay_msintegeralways - the resolved delay, in milliseconds, as of when the send was scheduled

effect.cancel

Payload for <cancel> (spec 6.3).

FieldTypePresence
send_idstringalways - the sendid/sendidexpr naming the delayed send to cancel
c_indexintegerpresent only when known
ownerowner objectpresent only when known

effect.datamodel_change

Emitted once per successful datamodel write (st-oef3): an <assign>, a <data> binding during the binding fold, a <send idlocation> write, an <invoke idlocation> write, or an empty-<finalize> auto-assign. A failed write emits nothing - the datamodel did not change, and the failure is already on the error channel. Together with session.datamodel's starting snapshot, the sequence of these messages reconstructs the datamodel at any point in the run from the stream alone.

FieldTypePresence
location_patharray of strings and integersalways - the resolved write path; see below
location_sourcestringalways - the raw author string that named the location (items[i].name as written)
new_valuevaluepresent unless the write stored the unbound sentinel; a stored null is present as null (three-way absence rule)
prior_valuevaluepresent unless nothing stood at the path before the write; a previously stored null is present as null (three-way absence rule)
d_indexintegerpresent only for a <data> binding - resolves through session.start's data table
c_indexintegerpresent only when a content node performed the write (<assign>, <send idlocation>)
ownerowner objectpresent only when known - which construct performed the write

location_path is a heterogeneous JSON array. Each segment is either a string - an object key, the variable name first - or an integer, a 0-based array index: user.items[0].name resolved against i = 0 arrives as ["user", "items", 0, "name"]. JSON's own typing carries the distinction - the string "0" is a key, the number 0 is an index - so no tagging or escaping is needed, and a consumer applies the segments in order to its own copy of the datamodel to reproduce the write. It is the resolved path: an index expression like [i] has already been evaluated by the engine, which is what makes the path applicable without the pre-assignment datamodel. location_source is the raw author string kept alongside for display; neither substitutes for the other.

new_value and prior_value follow the _event.data three-way rule, because the engine genuinely distinguishes unbound from null here (statifier ADR-0037 spells unbound as :undefined): key absence means the slot was or became unbound - for prior_value, that nothing stood at the path before the write, the common case for a first assignment - while JSON null means a genuinely stored null.

d_index and c_index are mutually exclusive on this message: a d_index means the write was a <data> binding, which belongs to no content block and therefore also carries no owner; a c_index means a content node performed it. The two runner-side writes - the empty-<finalize> auto-assign and <invoke idlocation> - carry neither, identified by owner alone ("finalize" and "invoke" kinds respectively).

Versioning decision, recorded rather than left implicit: this type joined the format after version 1 shipped with 23 types. Adding a type is exactly what the conformance section's MUST-ignore rule makes additive, so the format version stays 1 - unlike session.datamodel, which kept version 1 because its type string was already reserved, this one keeps it because new types never bump the version at all.

Origins

Statifier.Event.Cause.origin/0's eight variants, each a tagged object with "kind" naming the tuple's tag:

"kind"FieldsMeaning
"content"c_index (integer), owner (owner object)a content node (for example <raise>) raised the event
"state"state_index (integer)the platform raised the event with no content node behind it (for example done.state.*)
"transition"t_index (integer)the platform raised the event about a transition's own cond
"data"d_index (integer)the platform raised the event about a <data> element that failed to bind
"donedata_param"state_index (integer), param_index (integer)one <param> under a <final>'s <donedata> failed to evaluate
"global_script"index (integer)a top-level <script> failed at load time
"invoke"state_index (integer), invoke_index (integer)one of an <invoke>'s own arguments failed to evaluate
"finalize"state_index (integer), invoke_index (integer)an empty <finalize>'s own auto-assign write failed

A d_index anywhere in this format - here or on effect.datamodel_change - resolves through session.start's data table, the same way a state_index resolves through states and a t_index through transitions.

Owners

Statifier.Machine.Content.owner/0's four variants, plus the case Statifier.Effect.Trace.ContentExecuted widens it with for a top-level <script> and the case Statifier.Effect.DatamodelChange widens it with for <invoke idlocation>, together the six variants an owner field may take anywhere in this format:

"kind"FieldsMeaning
"onentry"state_index (integer), ordinal (integer)an <onentry> block; ordinal is the block's position in the state's own onentry list
"onexit"state_index (integer), ordinal (integer)an <onexit> block; ordinal is the block's position in the state's own onexit list
"transition"t_index (integer)a transition's own executable content
"finalize"state_index (integer), invoke_index (integer)an <invoke>'s own <finalize> block
"global_script"index (integer)a top-level <script>, run at load time - only ever appears as trace.content_executed's owner, since a top-level script belongs to no <onentry>/<onexit>/transition block
"invoke"state_index (integer), invoke_index (integer)an <invoke idlocation> write - only ever appears as effect.datamodel_change's owner, since the write belongs to no content block

The session.* types

Five types name a session from the outside: its start, one snapshot of the values it started with, and the three ways a subscriber's own stream can end or change state. None of the five carry macrostep/microstep/ round - the four lifecycle types because they describe the stream, not a point inside a run, and session.datamodel because it precedes the first macrostep entirely (its underlying effect payload carries macrostep/ microstep/round internally, but this producer stays consistent with every other session.* type and leaves the envelope counters nil).

session.start

The definition message. Schema above under "session.start". Always seq: 0.

session.datamodel

Emitted exactly once per session, unconditionally - even under trace: false - as the second message on the stream, right after session.start, from statifier's Statifier.Effect.DatamodelInit (st-1xwh). It carries the datamodel's starting values: spec 5.3.3's unconditional <data> creation plus the four spec 5.10 system variables, before the binding fold that follows it.

Because the snapshot precedes that fold, every <data> element appears as {"$undefined": true}, not only the ones declared without a value: a <data id="count" expr="41 + 1"/> is present under count as {"$undefined": true} here, and is assigned its 42 afterwards. This message therefore names the datamodel's variables reliably, but is not a source for their values. The assignments that follow arrive as effect.datamodel_change messages (sui-h92), one per successful write, binding fold included - a consumer folds them over this snapshot to hold the current datamodel at any point in the run.

This message keys the datamodel by variable name; session.start's data table keys the same elements by d_index and carries the id that joins the two.

FieldTypePresence
datamodelobjectalways - the session's starting datamodel, keyed by variable name, values encoded by the $-tagged value discipline

session.halted

Emitted when the session reports {:halted, reason} to its subscriber.

FieldTypePresence
reasonstringalways - one of "done", "cancelled", "budget_exhausted"

As of ADR-0044 (st-r6l9), session.halted is terminal for that session id: no further messages for the same session id follow it (see "Ordering" above). Terminal is scoped per session id, not per mailbox - ADR-0050 lets a subscriber observe an invoke tree of related sessions on one mailbox, and a parent's session.halted does not end the stream for a child session still running on the same mailbox.

session.terminated

Emitted when the subscriber observes the session's process exit (a monitor :DOWN).

FieldTypePresence
reasonstringalways - inspect/1 of the Elixir exit reason

reason is a human-readable string, not structured data to branch on - an Elixir process exit reason has no language-neutral shape, so this document does not attempt to give it one.

session.unroutable

Emitted when the session reports {:unroutable, effect} to its subscriber

  • an effect the session could not route to a destination. Currently unreachable in the engine as shipped, reserved for when it becomes reachable.
FieldTypePresence
effectobjectalways - the unrouted effect, encoded the same way its own effect.* or trace.* type would encode it, under a kind key naming that type

Worked example

The complete JSON Lines trace of a two-state chart with one external transition. The producer ran with session_id: "sess_golden", attached early (Statifier.Trace.Subscriber's :subscribers-at-start_link path, docs/plans/260817-sui-t36.3-session-subscriber-and-trace-normalizer.md phase 5), so it sees the initialize burst that runs to quiescence before Statifier.Session.start_link/2 returns. This is the same fixture phase 5's golden test compares against byte-for-byte (test/support/trace/two_state.jsonl).

Chart:

<scxml xmlns="http://www.w3.org/2005/07/scxml" initial="a" version="1.0">
    <state id="a">
        <transition event="go" target="b"/>
    </state>
    <state id="b"/>
</scxml>

xmlns and version are required attributes on the root element (Statifier.Validator); a chart missing either fails to compile, so an example chart must carry both even though they add nothing to the trace itself.

Driven with one external event, "go", with no data, after the session has already come up and settled into a on its own. Trace (one JSON object per line, each shown here with lexicographic keys exactly as the wire form produces - the actual bytes, not a reformatting):

{"contents":[],"data":[],"seq":0,"session":"sess_golden","states":[{"children":[1,2],"index":0,"kind":"scxml","location":{"end_column":9,"end_line":6,"end_offset":178,"start_column":1,"start_line":1,"start_offset":0},"transitions":[]},{"children":[],"id":"a","index":1,"kind":"state","location":{"end_column":13,"end_line":4,"end_offset":149,"start_column":5,"start_line":2,"start_offset":78},"parent":0,"transitions":[0]},{"children":[],"id":"b","index":2,"kind":"state","location":{"end_column":20,"end_line":5,"end_offset":169,"start_column":5,"start_line":5,"start_offset":154},"parent":0,"transitions":[]}],"transitions":[{"content":[],"events":[["go"]],"location":{"end_column":44,"end_line":3,"end_offset":136,"start_column":9,"start_line":3,"start_offset":101},"source":1,"t_index":0,"targets":[2],"type":"external"}],"type":"session.start","version":1}
{"datamodel":{"_event":{"$undefined":true},"_ioprocessors":{"http://www.w3.org/TR/scxml/#SCXMLEventProcessor":{"location":"#_scxml_sess_golden"}},"_name":{"$undefined":true},"_sessionid":"sess_golden"},"seq":1,"session":"sess_golden","type":"session.datamodel"}
{"indexes":[0,1],"macrostep":1,"microstep":1,"round":0,"seq":2,"session":"sess_golden","type":"trace.entry_set"}
{"macrostep":1,"microstep":1,"round":1,"seq":3,"session":"sess_golden","t_indexes":[],"type":"trace.transitions_selected"}
{"invoke_ids":[],"macrostep":1,"microstep":1,"round":1,"seq":4,"session":"sess_golden","state_indexes":[0,1],"type":"trace.invoke_pass"}
{"configuration":[0,1],"macrostep":1,"microstep":1,"round":1,"seq":5,"session":"sess_golden","type":"trace.macrostep_stable"}
{"event":{"name":"go","type":"external"},"from":"external","macrostep":2,"microstep":0,"round":0,"seq":6,"session":"sess_golden","type":"trace.event_dequeued"}
{"event":{"name":"go","type":"external"},"finalized":[],"forwarded":[],"macrostep":2,"microstep":0,"round":0,"seq":7,"session":"sess_golden","type":"trace.finalize_autoforward"}
{"event":{"name":"go","type":"external"},"macrostep":2,"microstep":0,"round":0,"seq":8,"session":"sess_golden","t_indexes":[0],"type":"trace.transitions_selected"}
{"indexes":[1],"macrostep":2,"microstep":1,"round":0,"seq":9,"session":"sess_golden","type":"trace.exit_set"}
{"c_indexes":[],"macrostep":2,"microstep":1,"owner":{"kind":"transition","t_index":0},"round":0,"seq":10,"session":"sess_golden","type":"trace.content_executed"}
{"indexes":[2],"macrostep":2,"microstep":1,"round":0,"seq":11,"session":"sess_golden","type":"trace.entry_set"}
{"macrostep":2,"microstep":1,"round":1,"seq":12,"session":"sess_golden","t_indexes":[],"type":"trace.transitions_selected"}
{"invoke_ids":[],"macrostep":2,"microstep":1,"round":1,"seq":13,"session":"sess_golden","state_indexes":[2],"type":"trace.invoke_pass"}
{"configuration":[0,2],"macrostep":2,"microstep":1,"round":1,"seq":14,"session":"sess_golden","type":"trace.macrostep_stable"}

seq starts at 0 on session.start and increments by one across every subsequent message, with no gaps - this chart never touches the <invoke>/ internal-<send> reordering seam, so a producer's live delivery order and (macrostep, round) order agree throughout, and the trace above is byte-comparable run to run. session.datamodel at seq 1 always follows session.start and always precedes the first trace.* message, carrying no macrostep/microstep/round of its own. macrostep 1 is the session's own initialize burst (Appendix D's initialize procedure, entering a before any event is ever sent - configuration on trace.macrostep_stable is [0, 2] later, not [2], because the synthesized root at index 0 is always a member); macrostep 2 is the driven "go" transition into b. trace.content_executed appears once, for the transition's own (empty) executable content list; trace.done does not appear because this run never halts through a <final> state - a chart reaching one would show it in the shape its schema above describes.

Type index

One row per type this document defines - 24 rows: 9 trace.*, 10 effect.*, and 5 session.* (the four lifecycle types plus session.datamodel). This table's first column is a machine boundary: a drift test parses exactly this table's backtick-quoted type strings and asserts them equal to the producer's own emitted type set, so a type documented here and not emitted, or emitted and not documented here, fails that test rather than drifting silently.

TypeFamilyEmitted when
trace.event_dequeuedtracean event is dequeued for processing
trace.transitions_selectedtracetransition selection runs
trace.exit_settracebefore states are exited
trace.content_executedtracea block of executable content runs
trace.entry_settracebefore states are entered
trace.macrostep_stabletracethe configuration reaches quiescence
trace.donetracealongside top-level final entry
trace.invoke_passtracethe invoke pass finishes
trace.finalize_autoforwardtracethe finalize/autoforward pass finishes
effect.logeffect<log> runs
effect.doneeffectthe interpreter terminates
effect.budget_exhaustedeffecta macrostep's round budget is spent without quiescence
effect.invokeeffectan invocation starts
effect.cancel_invokeeffecta live invocation's owning state exits
effect.autoforwardeffectan event is autoforwarded to an invocation
effect.sendeffect<send> fires immediately
effect.send_delayedeffect<send> with a delay is scheduled
effect.canceleffect<cancel> runs
effect.datamodel_changeeffecta datamodel location is successfully written
session.startsessiona session's stream opens
session.haltedsessionthe session reports {:halted, reason}
session.terminatedsessionthe session's process exits
session.unroutablesessionthe session reports an unroutable effect
session.datamodelsessiona session's datamodel is initialized

References

  • ADR-0005 (docs/adr/0005-language-neutral-trace-wire-format.md) - the envelope, the nine trace.* type names, the session.start role, the JSON discipline this document restates by reference, versioning, and the clause naming this document as the format's normative home.
  • ADR-0003 (docs/adr/0003-fixtures-as-the-example-data-contract.md) - the fixtures sidecar object session.start's fixtures field carries verbatim.
  • st-nbmj - the upstream gap behind effect.* messages once carrying no round, superseded by st-xb2b, whose ADR-0046 settled it (the two ids name one thread of work: st-nbmj filed the gap, st-xb2b decided it); this producer's own propagation of round onto effect.* messages was sui-67d, above.
  • st-r6l9 - the upstream reordering seam behind the old ordering warning above, closed by ADR-0044.
  • st-1xwh - the upstream effect (Statifier.Effect.DatamodelInit) behind session.datamodel, above.
  • st-oef3 - the upstream effect (Statifier.Effect.DatamodelChange) behind effect.datamodel_change - a per-write value change, distinct from session.datamodel's one-time starting snapshot; serialized here by sui-h92.
  • sui-qay - the gap behind session.start's location tables carrying no attribute-level spans.