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.
Skipped trace effects
normalize/2 has a third answer, :skip, for an engine trace effect the
v1 wire format deliberately does not carry. It is not a failure and it is
not an unknown effect: the set is closed, named in
@skipped_trace_effects, and every member has been looked at and ruled
out of v1 on purpose. Today the set is
Statifier.Effect.Trace.CondsEvaluated alone - statifier 2.5.0's guard
seam, emitted once per selection round that evaluated a written cond.
The vocabulary has no guard-evaluation message to map it onto, and
inventing one is a wire-format change with an ADR in front of it (sui-e41
will add trace.conds_evaluated), so until then the producer passes over
the effect rather than refusing the stream it appears in. Refusing it is
what this skip replaces: a chart with a single guarded transition used to
fail the whole offline replay on its first branch.
The fallthrough that answers {:error, {:unknown_effect, _}} is
untouched, and deliberately so - a trace effect nobody has considered
still refuses.
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()} | :skip | {: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.
Returns :skip for an engine trace effect the wire format deliberately
does not carry - the closed @skipped_trace_effects set, today
Statifier.Effect.Trace.CondsEvaluated alone. :skip is not a failure:
the effect produced no message because v1 has no message for it, so a
caller consumes no seq for it, records no error, and carries on. See
this module's "Skipped trace effects" section. A caller matching only
{:ok, _} and {:error, _} needs a third clause.
@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.