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

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 seventeen 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 `name`s, `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`) adds six more
never-projected fields: `class`, `kind`, `span`, `location`,
`location_kind`, and `content_path`. Two of its keys are not in that set.
`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. `error.reason` is redacted **unconditionally**, under every
profile, beside `session.terminated`'s `reason`: it is `inspect/1` of an
engine reason term and can embed a datamodel value verbatim (ADR-0014
decision 5), so no profile can allow it back and it is not a position
either.

## 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.

# `positions`

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

# `profile`

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

```elixir
@spec profile!(
  String.t(),
  keyword()
) :: StatifierUI.Trace.Projection.Profile.t()
```

`profile/2`, raising on an invalid allowlist.

# `project`

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

# `projected_types`

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

```elixir
@spec redacted() :: %{required(String.t()) =&gt; boolean()}
```

The reserved sentinel object a redacted position carries.

## Examples

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

---

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