# `StatifierBlocks.Compiler.SensitivePaths`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/compiler/sensitive_paths.ex#L1)

The secrets rule, checked against a document: a declared-sensitive
datamodel path may not be read into a trace-visible position (ADR-0002,
the accepted 2026-08-29 amendment "decision 7, an optional `sensitive?`
key, and the secrets rule behind it").

The rule the key serves is a rule about hosts: **credentials, API keys
and other secrets never enter a chart datamodel.** A secret is
referenced by an identifier and fetched by the invoke handler at effect
time. This package emits SCXML and never sees a value, so it cannot
protect one; what it can do is refuse to compile a document that would
carry a value it has been told is a secret into a position one of the
five leak surfaces reads - traces, telemetry, job payloads, the editor's
fixtures and truth tables, LiveView diffs.

Findings, not runtime checks. Every word here is about what a document
says, checked at compile time.

## No datamodel supplied, nothing produced

The check runs only when the caller supplies a datamodel that declares
at least one path sensitive. With none, `check/2` returns `[]`, makes no
claim, and reports nothing anywhere - the qualifier ADR-0005's `11f`
states for the undeclared-path advisories, in `11f`'s own words:
absence is not unknown-ness. A host that has described nothing has
claimed nothing.

## The criterion, and why it is not a list of block types

A hard-coded list of `core.*` fields would have to be edited every time
the vocabulary grew, and a type added without that edit would leak
silently. So the pass never looks at a block type's name. It walks the
**emission** - the SCXML this compile is about to serialize, already
attributed back to the config fields the author typed (ADR-0004
decision 9) - and classifies each attribute by SCXML's own declared
semantics: is this an attribute the engine **evaluates against the
datamodel**?

`datamodel_position?/1` is that criterion. `cond`, `location`,
`namelist`, `idlocation` and every `*expr` attribute (`expr` itself,
`eventexpr`, `targetexpr`, `delayexpr`, `typeexpr`, `srcexpr`) are
datamodel positions; every other attribute is literal chart text the
engine never resolves against the datamodel. A block type that lands
tomorrow and writes a sensitive path into an `expr` is refused without
this module having heard of it.

The record's four refused positions all fall out of that one criterion,
which is the check that it is the right criterion rather than a
rephrasing of the list:

| Record's position | The emission it lands in |
|---|---|
| a `core.invoke` param | `<param expr="card.number" name="amount"/>` |
| a `core.send` payload | the `expr` it will carry when the row lands |
| a `core.assign` target | `<assign location="card.number"/>` |
| a `core.assign` source | `<assign expr="card.number"/>` |
| a `core.branch` arm predicate | `<transition cond="card.number > 0"/>` |

## `core.invoke`'s `assign_to`, classified

The record's reviewer left this one explicitly to the compiler half, and
the criterion answers it without a special case: `assign_to` emits
`<assign expr="_event.data" location="authorization"/>`, and `location`
is a datamodel position. **`assign_to` is a datamodel WRITE target, the
exact analogue of `core.assign`'s target, and it is refused on the same
side of the line for the same reason** - the record's `core.assign`
clause is "either direction: writing a sensitive value somewhere else
spreads it, reading one out publishes it", and a write is a write
whichever type spells it. Nothing in this module distinguishes the two,
which is the point.

## What is not refused

A read of a sensitive path in a position that never leaves the session.
The criterion is stated as a criterion because in today's accepted
`core.*` vocabulary that side of the line is **empty**: every position
this package emits that can address the datamodel at all is a datamodel
position by the rule above. Two things are clearly on that side and are
not reads at all, so they are not refused:

  * **The declaration itself.** Annotating a path is not a read of it.
  * **The identifier pattern the rule prescribes.** A param reading
    `card.token_id` - a declared path that is not sensitive, holding the
    identifier the handler exchanges at effect time - is not a read of a
    sensitive path. The refusal must not grow into a suspicion of any
    field near a secret, and it does not: only a path the datamodel
    declares sensitive is ever matched.

A genuinely session-local position arrives by an amendment naming it and
the surfaces it is shown to miss, per the record. It does not arrive by
this module reading the criterion generously.

## Severity: `:error`

The record named the severity as this half's question. It is `:error`.
ADR-0005 `11a`'s own wording fixes it: "`:error` says the document does
not compile", and a refusal stops the compile - that is what makes it a
refusal rather than an advisory. `:info` is excluded by `11a` and `11c`
together (an advisory changes no verdict; this changes the verdict), and
`:warning` is excluded because a warning rides on
`StatifierBlocks.Compiled`'s `warnings` and lets the document through,
which would emit the leak it exists to prevent.

Decision 11's source list puts this at `:lint`: the rule lives neither
in `validate_config/1` (which is handed one config and has no datamodel
to check it against) nor in arity, assignability or resolution.
Decision 11 fixes `:lint` as the only source permitted to produce a
severity other than `:error`; it does not forbid `:lint` producing one,
and `11b`/`11e` reserve `:info` to `:lint` without making `:lint`
advisory-only.

`StatifierBlocks.Finding.from_compiler/2`'s **default** derivation
cannot reach `{source: :lint, severity: :error}` - its rule 2 maps only
a non-error to `:lint`, and this finding's `:emit` stage at `:error`
maps to `:compile` under ADR-0005 amendment `11h`, not to `:lint`. That
is what `opts[:source]` is documented for ("lets a caller that knows
better than the default rule say so explicitly"), so a caller adapting
these findings for presentation passes it:

    {presentation, []} =
      StatifierBlocks.Finding.from_compiler_all(findings, source: :lint)

Widening the default derivation would mean switching on `code`, which
`StatifierBlocks.Finding` forbids by construction; the seam is named
here rather than bent.

# `datamodel`

```elixir
@type datamodel() :: %{
  declared: MapSet.t(String.t()),
  sensitive: MapSet.t(String.t())
}
```

The host's declared datamodel, normalized.

`declared` is the declared-path set - the shipped editor's normalized
input, and what ADR-0005 `11f` names as the shape this check needs.
`sensitive` is the subset those declarations annotate `sensitive?: true`
(ADR-0002 decision 7's key).

This pass reads `sensitive`. `declared` is carried because it is the
same input the undeclared-path advisory of `11e` reads, so a host
supplies one datamodel rather than two, and because the typed scoped
datamodel document ADR-0006 accepts derives both by one total
function.

# `check`

```elixir
@spec check(StatifierBlocks.Emission.t(), term()) :: [
  StatifierBlocks.Compiler.Finding.t()
]
```

Every refusal `emission` earns against `supplied`, in the order the
emission is walked (document order over blocks, attributes sorted by
name, which is `StatifierBlocks.Emission.element/3`'s normalization).

`supplied` is the raw option value; it is normalized through
`datamodel/1` here so a caller passes what it has. With no sensitive
path in it the walk is skipped entirely and `[]` is returned.

# `datamodel`

```elixir
@spec datamodel(term()) :: datamodel()
```

Normalizes a caller-supplied datamodel into `t:datamodel/0`.

Total, and lenient about shape rather than about content: only non-empty
binaries are taken as paths, and anything else in a supplied collection
is ignored. Accepted shapes:

  * `nil` - no datamodel. Both sets empty; the check does not run.
  * a list or `MapSet` of paths - the declared-path set, with nothing
    annotated sensitive. Nothing is produced, which is correct: a host
    that declared paths and annotated none has claimed no secrets.
  * a map with `:declared` and/or `:sensitive`, each a list or `MapSet`.

A path in `sensitive` that is absent from `declared` is still sensitive.
This pass does not adjudicate declaredness - that is `11e`'s advisory
and a different finding - and dropping an annotated path because the
declared set did not repeat it would silently disable the refusal.

    iex> alias StatifierBlocks.Compiler.SensitivePaths
    iex> SensitivePaths.datamodel(%{sensitive: ["card.number"]}).sensitive |> MapSet.to_list()
    ["card.number"]
    iex> SensitivePaths.datamodel(nil).sensitive |> MapSet.size()
    0

# `datamodel_position?`

```elixir
@spec datamodel_position?(String.t()) :: boolean()
```

Whether an attribute of that name is a position the engine evaluates
against the datamodel. See the moduledoc; this is the criterion, and the
only thing that decides what is refused.

    iex> alias StatifierBlocks.Compiler.SensitivePaths
    iex> {SensitivePaths.datamodel_position?("expr"), SensitivePaths.datamodel_position?("cond")}
    {true, true}
    iex> {SensitivePaths.datamodel_position?("eventexpr"), SensitivePaths.datamodel_position?("event")}
    {true, false}

# `reads`

```elixir
@spec reads(String.t(), MapSet.t(String.t())) :: [
  {:exact, String.t()} | {:prefix, String.t(), String.t()}
]
```

The sensitive paths `value` reads, sorted, with how each one was
reached.

`{:exact, path}` is the path itself or something under it;
`{:prefix, token, path}` is a read of a prefix, which "drags the
sensitive leaf along with everything else under it, so a prefix read is
the same leak spelled shorter".

Public because the tests assert the matcher directly - a matcher that
only ever runs behind a whole compile is one whose edge cases are
asserted by proxy.

---

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