The pure mapping from Statifier.Effect.t() (and the session's own
lifecycle messages) to StatifierUI.Trace.Message.t(), matching
docs/wire-format.md field for field. No process, no session, no
%Statifier.Machine{} - the whole vocabulary is testable from a struct
literal.
Value handling
Anything from the engine that occupies a value position -
Event.data, Log.value, Trace.Done.donedata, Effect.Done.donedata,
Send.data, SendDelayed.data, Invoke.params, Invoke.content,
DatamodelChange.new_value, DatamodelChange.prior_value - goes
through StatifierUI.Value.encode/1; its {:error, _} is propagated,
never rescued. Everything structural is reduced first (decision 6):
MapSets to sorted lists (configuration/1), atoms to "kind"-tagged
objects (origin/1, owner/1). This ordering matters -
StatifierUI.Value.encode/1 rejects any term outside predicator's
closed value domain with {:error, {:unsupported_value, term}}, so
handing it a raw tuple or a bare atom would produce a wrong error
(:unsupported_value instead of a normalizer-specific one) rather than
the structural, purpose-built handling this module gives those shapes.
A %Statifier.Evaluator.Error{} in Event.data gets the same
structural treatment, one step further: it never reaches
StatifierUI.Value.encode/1 at all, because it is not a predicator
value - it is reduced by StatifierUI.Trace.Diagnostic.object/4 into
the wire error object and lands on the event's "error" key instead
of "data". The two keys are alternatives, never siblings.
An error.execution or error.communication event's data gets the same
treatment for the same reason (ADR-0014): the engine's raise site puts an
unconstrained reason term there, StatifierUI.Value.encode/1 rejects the
tagged tuples it is in practice, and the whole message used to be dropped
rather than rendered. It is reduced by
StatifierUI.Trace.Diagnostic.reason_object/4 onto the same "error"
key instead.
Absence
_event.data keeps ADR-0005's three-way rule unchanged: :undefined
omits the key, nil is present as JSON null, %{} is present as {}
(put_defined/3). DatamodelChange.new_value and
DatamodelChange.prior_value follow the same three-way rule, because they
share _event.data's property: the engine spells "unbound" as
:undefined there (ADR-0037), so nil genuinely means a stored null.
Every other nullable field in this vocabulary has no
engine-side way to distinguish "no value" from "a genuinely null value" -
Statifier.Effect.Log.value and the two donedata fields default to
plain nil for "nothing here", never :undefined - so put_present/3
and put_value/3 generalize the rule (decision 5) by treating nil and
:undefined alike as absence for those fields, applied uniformly instead
of remembered clause by clause.
Summary
Types
What normalize/2 needs beyond the effect itself: the emitting session
and the seq to stamp, always required. :machine and :source are
optional - present only when the caller (StatifierUI.Trace.Subscriber)
can supply the compiled machine and the chart text, which is what lets
StatifierUI.Trace.Diagnostic.object/4 resolve an absolute location
for an %Evaluator.Error{} event. Without them the error object still
carries kind/expression/span; only location/location_kind are
omitted. Optional rather than required on purpose: every existing test
in this module constructs ctx as a two-key struct literal, and the
moduledoc's "no process, no session, no %Statifier.Machine{}" claim
stays true for the rest of the vocabulary.
Everything normalize/2 accepts: a bare effect, the {:effect, _}
wrapper the session sends its subscribers, and the two lifecycle
messages. The outer {:statifier, session_id, _} envelope is never
accepted here - unwrapping it is the subscriber's job, and keeping it out
is what makes this module testable from a struct literal.
Functions
Normalizes one input() into a %Message{} stamped with ctx's
session and seq.
The closed, sorted list of every type string this format defines - the
vocabulary's single definition site in code. 24 entries: 9 trace.*, 10
effect.* (the nine core effects plus effect.datamodel_change, from
Statifier.Effect.DatamodelChange), and 5 session.* (the four
lifecycle types plus session.datamodel, emitted once per session from
Statifier.Effect.DatamodelInit). docs/wire-format.md's type index
table is the same 24, and test/statifier_ui/trace/wire_format_spec_test.exs
asserts the two sets are equal.
Types
@type ctx() :: %{ :session => String.t(), :seq => non_neg_integer(), optional(:machine) => Statifier.Machine.t(), optional(:source) => String.t() }
What normalize/2 needs beyond the effect itself: the emitting session
and the seq to stamp, always required. :machine and :source are
optional - present only when the caller (StatifierUI.Trace.Subscriber)
can supply the compiled machine and the chart text, which is what lets
StatifierUI.Trace.Diagnostic.object/4 resolve an absolute location
for an %Evaluator.Error{} event. Without them the error object still
carries kind/expression/span; only location/location_kind are
omitted. Optional rather than required on purpose: every existing test
in this module constructs ctx as a two-key struct literal, and the
moduledoc's "no process, no session, no %Statifier.Machine{}" claim
stays true for the rest of the vocabulary.
@type input() :: Statifier.Effect.t() | {:effect, Statifier.Effect.t()} | {:halted, :done | :cancelled | :budget_exhausted} | {:unroutable, Statifier.Effect.t()}
Everything normalize/2 accepts: a bare effect, the {:effect, _}
wrapper the session sends its subscribers, and the two lifecycle
messages. The outer {:statifier, session_id, _} envelope is never
accepted here - unwrapping it is the subscriber's job, and keeping it out
is what makes this module testable from a struct literal.
Functions
@spec normalize(input(), ctx()) :: {:ok, StatifierUI.Trace.Message.t()} | {:error, term()}
Normalizes one input() into a %Message{} stamped with ctx's
session and seq.
Returns {:error, {:unknown_effect, tag}} for a tag this module does not
know, rather than skipping it - a new engine effect appearing silently is
exactly what ADR-0005's Consequences warn a drift test must catch. It is
the caller's job to decide what to do with the error.
@spec types() :: [String.t()]
The closed, sorted list of every type string this format defines - the
vocabulary's single definition site in code. 24 entries: 9 trace.*, 10
effect.* (the nine core effects plus effect.datamodel_change, from
Statifier.Effect.DatamodelChange), and 5 session.* (the four
lifecycle types plus session.datamodel, emitted once per session from
Statifier.Effect.DatamodelInit). docs/wire-format.md's type index
table is the same 24, and test/statifier_ui/trace/wire_format_spec_test.exs
asserts the two sets are equal.