# `StatifierUI.Trace.Normalizer`
[🔗](https://github.com/riddler/statifier-ui/blob/v0.11.0/lib/statifier_ui/trace/normalizer.ex#L1)

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):
`MapSet`s 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, in either half. Keep the clause with an empty list and
`module in []` expands to `false`, so the pattern's own binding is never
read and the compiler answers `variable "module" is unused`; drop the
clause and keep the constant instead and it answers `module attribute
@skipped_trace_effects was set but never used`. Both are fatal under
`warnings_as_errors`, 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.

# `ctx`

```elixir
@type ctx() :: %{
  :session =&gt; String.t(),
  :seq =&gt; non_neg_integer(),
  optional(:machine) =&gt; Statifier.Machine.t(),
  optional(:source) =&gt; 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.

# `input`

```elixir
@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.

# `normalize`

```elixir
@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.

# `types`

```elixir
@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.

This is the vocabulary's public handle: a host holds this list at the
version it integrated against and diffs it on upgrade to notice an added
type, which the format's must-ignore rule otherwise makes invisible. See
the 2026-09-06 note in
`docs/adr/0005-language-neutral-trace-wire-format.md`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
