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.
The retired third answer
normalize/2 used to have a third answer, :skip, for an engine trace
effect the wire format deliberately did not carry - closed, named, and
distinct from an unknown effect. It had exactly one member,
Statifier.Effect.Trace.CondsEvaluated, skipped by sui-9fs because v1
had no guard-evaluation message to map it onto. ADR-0018 added that
message, sui-e41 moved the effect onto the mapping clause below, and the
answer retired with its last member: nothing in this module returns
:skip, and normalize/2 no longer offers it.
It was retired rather than kept empty because the empty form is not writable here. A guard over an empty list warns that its clause cannot match, an unused constant warns on its own, and a contract offering a return no clause produces makes every caller's third arm dead code the analyzer refuses. The next trace effect ruled out of the vocabulary reintroduces the answer with the clause that produces it, which is the only form that stays honest.
The fallthrough that answers {:error, {:unknown_effect, _}} is
untouched, and deliberately so - a trace effect nobody has considered
still refuses. That refusal is the mechanism the skip was always distinct
from, and it is the half that has members.
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. 25 entries: 10 trace.* (the
nine Appendix D phase boundaries plus trace.conds_evaluated, the guard
seam inside selection, from Statifier.Effect.Trace.CondsEvaluated), 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 25, 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.
There is no third answer. normalize/2 returned :skip while one engine
trace effect had no message to map onto; ADR-0018 gave it one, so every
effect this module accepts now produces a message or an error. See this
module's "The retired third answer" section.
@spec types() :: [String.t()]
The closed, sorted list of every type string this format defines - the
vocabulary's single definition site in code. 25 entries: 10 trace.* (the
nine Appendix D phase boundaries plus trace.conds_evaluated, the guard
seam inside selection, from Statifier.Effect.Trace.CondsEvaluated), 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 25, and test/statifier_ui/trace/wire_format_spec_test.exs
asserts the two sets are equal.