One message of the trace wire format (docs/wire-format.md), held as a
struct in process and rendered to the documented JSON object by to_map/1.
The envelope fields (type, session, seq, the counters
macrostep/microstep/round, carried by trace.* and effect.*
messages and by no session.* message, and the optional otel
correlation object legal in exactly the same places) live on the struct
directly; everything
type-specific lives in payload, already in wire shape - a string-keyed
map ready to merge over the envelope. This is decision 1 of the plan: one
envelope struct rather than one struct per message type, so the spec stays
the single definition site of what a payload contains.
A payload key is never allowed to collide with an envelope key - that
would be a producer bug, silently resolved by whichever side to_map/1
happened to favor. validate/1 catches it explicitly; StatifierUI.Trace.Normalizer
calls it on every message it builds, so by the time anything else sees a
%Message{} the invariant already holds and to_map/1 can be specced for
a valid message.
Summary
Types
Why from_map/1 refused a term.
A JSON-ready term - the codomain every payload value must land in.
Types
@type from_map_error() :: {:not_an_object, term()} | {:missing_envelope_key, String.t()} | {:invalid_envelope_value, String.t(), term()}
Why from_map/1 refused a term.
@type json() :: nil | boolean() | integer() | float() | String.t() | [json()] | %{optional(String.t()) => json()}
A JSON-ready term - the codomain every payload value must land in.
@type t() :: %StatifierUI.Trace.Message{ macrostep: non_neg_integer() | nil, microstep: non_neg_integer() | nil, otel: %{optional(String.t()) => String.t()} | nil, payload: %{optional(String.t()) => json()}, round: non_neg_integer() | nil, seq: non_neg_integer(), session: String.t(), type: String.t() }
Functions
@spec from_map(map()) :: {:ok, t()} | {:error, from_map_error()}
Reads a decoded JSON object back into a %Message{} - the inverse of
to_map/1.
The seven reserved keys become envelope fields and everything else
becomes payload, which is the whole of the transform: payload values
are not decoded. payload holds wire-shape terms by definition (see
the moduledoc), and the consumers that want Elixir terms call
StatifierUI.Value.decode/1 themselves at read time -
StatifierUI.DatamodelExplorer documents at its own decode site why
decoding a second time would re-read an already-decoded Date as a
$-tagged map. Staying structural is also what makes the round trip
exact: re-encoding the result of from_map/1 reproduces the bytes it was
read from.
Absent optional keys stay nil, mirroring to_map/1 omitting them.
A missing type, session, or seq is
{:error, {:missing_envelope_key, key}}; a present one of the wrong
shape is {:error, {:invalid_envelope_value, key, value}}. Both are
malformed input rather than producer bugs, so they are values and not
raises.
Examples
iex> StatifierUI.Trace.Message.from_map(%{"type" => "effect.log", "session" => "s", "seq" => 0})
{:ok, %StatifierUI.Trace.Message{type: "effect.log", session: "s", seq: 0}}
iex> StatifierUI.Trace.Message.from_map(%{"type" => "effect.log", "session" => "s"})
{:error, {:missing_envelope_key, "seq"}}
Renders message to the documented JSON object: the envelope merged over
the payload.
"type", "session", and "seq" are always present; "macrostep",
"microstep", "round", and "otel" are present only when non-nil
(decisions 3 and 5, amended by sui-67d - trace.* and effect.* messages
carry all three counters, session.* messages carry
none of the three; otel is stamped only where a counter is legal and
only when a host attached a resolver, ADR-0013). Specced for a valid
message: call validate/1 first, or construct through
StatifierUI.Trace.Normalizer, which always does.
Checks that message's payload carries no key reserved for the envelope.
Returns {:error, {:reserved_payload_key, key}} naming the first
colliding key found, rather than letting the envelope silently win in
to_map/1. Called by StatifierUI.Trace.Normalizer on every message it
builds, so any other caller can treat a %Message{} it receives as
already valid.
Examples
iex> message = %StatifierUI.Trace.Message{type: "effect.log", session: "s", seq: 0}
iex> StatifierUI.Trace.Message.validate(message)
{:ok, message}