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 has no default
source. 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.
Summary
Types
The host's declared datamodel, normalized.
Functions
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).
Normalizes a caller-supplied datamodel into datamodel/0.
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.
The sensitive paths value reads, sorted, with how each one was
reached.
Types
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.
Functions
@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.
Normalizes a caller-supplied datamodel into 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
MapSetof 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
:declaredand/or:sensitive, each a list orMapSet.
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
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}
@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.