ADR-0012's producer-side projection: the transform that replaces values in
a closed set of value positions with the reserved {"$redacted": true}
sentinel while leaving every identity, counter, ordering, and structural
field untouched.
project/2 is applied to a %StatifierUI.Trace.Message{} on the
StatifierUI.Trace.Subscriber path, after StatifierUI.Trace.Normalizer
has built the message and before it is buffered or fanned out to any
listener. That placement is the record's central decision and the reason
this is not a filter in StatifierUI.Trace.Json or in a consumer: every
shipped consumer reads structs and never passes through the encoder, so a
redaction placed at the encoder would be visible only to the golden tests.
The practical test the placement is chosen to pass: a projected stream may be buffered, rendered, encoded, written to disk, shipped to a log aggregator, or replayed months later without any of those having held a datamodel value.
Redaction replaces; it never omits
A redacted position carries %{"$redacted" => true}. It is never dropped,
never nil, never %{}, and never %{"$undefined" => true}. Key absence,
JSON null, and $undefined already mean specific things in this format,
and every one of them is a claim about what the run did; $redacted is the
format's only encoding for "a value was here and this stream is not
carrying it," which is a claim about the stream rather than about the run.
The corollary that governs every clause below: a position is replaced
only where it is already present. Nine of the sixteen key paths this
module touches are conditionally absent, and writing a sentinel into one
that was absent would assert something the run never did - an eventless
round becoming evented (trace.transitions_selected's event), a first
write acquiring a prior value (effect.datamodel_change's prior_value),
or a host that supplied no fixtures acquiring a bundle (session.start's
fixtures). replace_present/3 is the only way a value is written here.
The two allowlist shapes
Within projected mode the default is deny. A profile may allow specific values back, and the allowlist has two parts because the format's value positions divide cleanly in two.
Located positions - the datamodel - are allowlisted by path prefix,
written as arrays of segments in the same encoding
effect.datamodel_change's location_path already uses. The same prefixes
apply to session.datamodel, whose keys are the first segment of every
path.
Unlocated positions - payloads, which have no path - are allowlisted by
naming the position, from the closed set positions/0 returns. Naming a
position allows it wholesale, at every message that carries it. There is
deliberately no per-key allowlist inside a payload: an event payload has no
stable schema the way a datamodel location does.
Prefix matching, and the shallower-write rule
Three cases, and the third is the operator ruling of 2026-08-29 recorded on ADR-0012 (the accepted text settled only the first two):
- An allowed prefix matches the write's leading segments (the prefix is no longer than the write's path): the whole value is allowed through.
- No allowed prefix relates to the path at all: the whole value is redacted.
- An allowed prefix is longer than the write's path and extends it - the write is shallower than the prefix, so the written value contains both the allowed leaf and its withheld siblings. The projection descends into the value and redacts selectively, so the allowed leaf passes and every sibling is redacted. Allowing the whole write would leak a sibling the profile withheld; denying it would withhold a leaf the profile allowed.
Descent applies identically to session.datamodel's snapshot values. Where
a value cannot be descended into because it is a scalar rather than a map
or a list, the allowed leaf is unreachable and the value is redacted whole
- the safe direction.
What is never projected
Every identity, counter, and structural field: type, session, seq,
macrostep, microstep, round, otel (a trace id and a span id are
random identifiers carrying no chart vocabulary and nothing derived from a
datamodel value - ADR-0013), state indexes, t_index, c_index,
d_index, invokeid, send_id, state_index, invoke_index, every
session.start table and every location object in them, configurations,
exit and entry sequences, kind and type discriminators, owner and
origin objects, event names, label on effect.log, src and
invoke_type on effect.invoke, target and send_type on the send
family, location_path and location_source on effect.datamodel_change,
session.halted's reason (a closed three-value set), and effect.done's
configuration.
location_path's integer segments are resolved index expressions, so a
projected stream still reveals which array index a write landed on. ADR-0012
records that as an accepted residual: a consumer that cannot see the path
cannot fold the write at all.
An event's error object (StatifierUI.Trace.Diagnostic.object/4) adds
four more never-projected fields: kind, span, location, and
location_kind. Only error.expression is chart text and is redacted with
session.start's source under allow_source: false - it is not a new
entry in positions/0, since the existing allow_source knob already
governs chart text.
Not anonymization, not access control
Structure leaks. Which branch a run took, how many rounds a macrostep needed, which transition fired on which event - these imply things about the values that produced them. The guarantee is narrow and worth stating in those terms: no datamodel value crosses the producer boundary. Choosing the profile correctly per tenant is the host's job, and this format cannot check it.
Summary
Functions
The closed set of unlocated value positions a profile may name in
allow_positions.
Builds a validated Profile.
profile/2, raising on an invalid allowlist.
Projects message under profile.
The closed, sorted list of every message type this module has a
projection rule for - the code side of ADR-0012's position table.
The reserved sentinel object a redacted position carries.
Functions
@spec positions() :: [StatifierUI.Trace.Projection.Profile.position()]
The closed set of unlocated value positions a profile may name in
allow_positions.
Examples
iex> :log_value in StatifierUI.Trace.Projection.positions()
true
@spec profile( String.t(), keyword() ) :: {:ok, StatifierUI.Trace.Projection.Profile.t()} | {:error, term()}
Builds a validated Profile.
opts:
:allow_paths- a list of path prefixes, each a list of string or integer segments. Default[](deny every datamodel value).:allow_positions- a list of atoms drawn frompositions/0. Default[](deny every payload value).:allow_source- whethersession.start'ssourceis retained. Defaulttrue, which is ADR-0012's default:sourceis authored rather than run data and the whole inspector resolves indexes against it. Set itfalsewhen chart source may itself carry a secret, at the documented cost that location objects still resolve to line and column but nothing can display the text at them.
Returns {:error, {:invalid_allow_paths, term}} or
{:error, {:invalid_allow_positions, term}} rather than accepting an
allowlist it cannot interpret - a redaction rule that silently stops
matching is the failure ADR-0012 exists to avoid.
Examples
iex> {:ok, profile} = StatifierUI.Trace.Projection.profile("end_user_run_history")
iex> profile.allow_source
true
iex> StatifierUI.Trace.Projection.profile("bad", allow_positions: [:nope])
{:error, {:invalid_allow_positions, [:nope]}}
@spec profile!( String.t(), keyword() ) :: StatifierUI.Trace.Projection.Profile.t()
profile/2, raising on an invalid allowlist.
@spec project(StatifierUI.Trace.Message.t(), StatifierUI.Trace.Projection.Profile.t()) :: StatifierUI.Trace.Message.t()
Projects message under profile.
Every value position ADR-0012's table names is replaced with the sentinel
unless the profile allows it; every other field is returned untouched. A
session.start message additionally gains the projection header naming
the mode and the profile, which is what makes a projected stream
distinguishable from a full one even when a single message is pulled out of
a log.
Examples
iex> profile = StatifierUI.Trace.Projection.profile!("p")
iex> message = %StatifierUI.Trace.Message{
...> type: "effect.log", session: "s", seq: 1, payload: %{"value" => 42}
...> }
iex> StatifierUI.Trace.Projection.project(message, profile).payload
%{"value" => %{"$redacted" => true}}
@spec projected_types() :: [projected_type()]
The closed, sorted list of every message type this module has a
projection rule for - the code side of ADR-0012's position table.
This exists to be compared against the table in docs/wire-format.md's
Projection section, which
test/statifier_ui/trace/projection_drift_test.exs does in both
directions. The standing drift risk ADR-0012 names is a value position
added to the format later with no projection rule, which would carry
values through a projected stream silently; the spec table is the
checklist, and that test is what makes the checklist bite.
Examples
iex> "effect.log" in StatifierUI.Trace.Projection.projected_types()
true
The reserved sentinel object a redacted position carries.
Examples
iex> StatifierUI.Trace.Projection.redacted()
%{"$redacted" => true}