StatifierUI.Trace.Projection (StatifierUI v0.3.0)

Copy Markdown View Source

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

positions()

The closed set of unlocated value positions a profile may name in allow_positions.

Examples

iex> :log_value in StatifierUI.Trace.Projection.positions()
true

profile(name, opts \\ [])

@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 from positions/0. Default [] (deny every payload value).
  • :allow_source - whether session.start's source is retained. Default true, which is ADR-0012's default: source is authored rather than run data and the whole inspector resolves indexes against it. Set it false when 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]}}

profile!(name, opts \\ [])

profile/2, raising on an invalid allowlist.

project(message, profile)

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}}

projected_types()

@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

redacted()

@spec redacted() :: %{required(String.t()) => boolean()}

The reserved sentinel object a redacted position carries.

Examples

iex> StatifierUI.Trace.Projection.redacted()
%{"$redacted" => true}