The path/type index over a datamodel document (sb ADR-0006, accepted 2026-08-29), and the projections that record promises.
ADR-0006 defines the document: a version plus three scopes - global,
local, event - each carrying entries with name, path, type and
label, plus the optional fields, item_type, example, note and
one_of. Decision 5 makes every path absolute and globally addressable
(an event entry spells its own event. prefix), and decision 6 gives the
one total function from the document to the declared-path set.
This module is that record's reader. index/1 admits a decoded document
and flattens it to path -> entry, at every nesting depth; the lookups
answer what a path's type is, what lies under a prefix, and whether a
path is declared at all; declared_paths/1 is decision 6's projection.
Advisory, never a gate
Decision 9 is the stance this module is built to keep: an undeclared
path is unknown, not wrong. Nothing here returns a verdict, produces a
StatifierBlocks.Finding, or refuses anything. declared?/2 answers a
question about the document, not about the author, and type/2 returns
nil for a path the document does not declare rather than an error -
absence of a declaration is not a claim that the path is bad.
Making an undeclared path a validation failure needs a record saying so,
which is what sb-oiq asked for from the start and what decision 9
granted. Two consumers already read a datamodel and neither gates on
declaredness: StatifierBlocks.Datamodel reports :info (ADR-0005
11e), and StatifierBlocks.Compiler.SensitivePaths refuses only on the
separate sensitive? claim, which is a claim the host made rather than
one this package inferred.
The admission step is index/1
Decision 6 states its totality "over admitted documents: a malformed
input is a loader concern, rejected before this function is reached,
which is why the function has no {:error, _} arm to explain". index/1
is that loader. It is itself total - an input it cannot admit returns
nil, on ADR-0002 amendment B3's total-normalizer discipline, the same
way StatifierBlocks.Datamodel.declared_paths/1 treats a shape it does
not know - and every function taking a t/0 is then total by
construction.
So the record's declared_paths(document) is spelled here as
document |> index() |> declared_paths()with the nil case being this input is not a datamodel document, which
is a different thing from the document declares nothing. That
distinction is decision 6's, stated there for exactly the same pair of
values: nil is no datamodel supplied, MapSet.new() is a host claim
that nothing is declared.
What normalization does, and what it declines to decide
Keys are read as the record writes them, from a decoded JSON map with string keys.
- Scope names contribute nothing to a path (decision 6), so a scope
map naming something other than the three is not rejected: its
entries are indexed with
scope: nil. Paths are already absolute, and dropping entries over a scope label would lose declared paths the projection is required to contain. - An entry whose
pathis not a non-empty string contributes no path, and itsfieldsare still walked. That is the only place this module departs from decision 6's literal code, which would put anilin the set; a set with anilin it is not a set of declared paths, and the record admits no such entry in the first place. - The type set is closed (decision 4). A
typeoritem_typeoutside the eight normalizes tonil- unknown, on the same stance as an undeclared path - rather than being carried through as a ninth type this package would then be rendering. - A repeated path keeps its first occurrence in document order. This is not a ruling on the record's open question about collisions across scopes: the declared-path set is a set either way, and a document that needs the question answered needs a loader lint, which is where the record leaves it.
nameis read and stored, and nothing here consumes it. The record carries an open question on how an event entry spells it; this module readspathalone for every derivation, exactly as decision 6 does, so the question stays open rather than being answered by use.
sensitive?, and why it is derived here rather than projected
ADR-0006 decision 7 says the entry map is where a per-path annotation
lives, in ADR-0002's optional-boolean convention, and that decision 6's
projection deliberately drops it: "a consumer that needs to know
whether a path is sensitive reads the document, never the set". ADR-0002's
accepted sensitive? amendment says the same from the other side - if the
document record lands, "the same boolean belongs on its per-entry shape".
sensitive_paths/1 is that read, and it is a second projection rather
than a widening of the first: declared_paths/1 returns paths and
nothing else, exactly as decision 6 specifies. datamodel/1 pairs the
two into the %{declared: MapSet, sensitive: MapSet} shape
StatifierBlocks.Compiler.SensitivePaths.datamodel/1 already normalizes,
so a host with a document can reach that pass without a second input.
An entry's flag is read literally, per entry. An object entry marked
sensitive does not stamp its fields, and does not need to: the
sensitive-path matcher already treats a read of a prefix as a read of
everything under it ("a prefix read is the same leak spelled shorter"),
so inheritance here would restate the matcher and, where the two
disagreed, would decide a rule no record has drawn.
Not wired to anything
This module has no consumer in this package, on purpose. sb-oiq filed
itself as "file this when something actually needs a path/type index",
and what needs it today is the derivation the accepted record promised -
not a fixtures or completion pane, which ADR-0005 decision 15 lists among
the things that record explicitly does not decide. The one seam taken is
the additive StatifierBlocks.Datamodel.declared_paths/1 clause, which
discharges 11f's promise for a host that has a document instead of a
set.
Summary
Types
One declared path, flattened out of the document.
ADR-0006 decision 5's three scopes, or nil for a scope map naming none of them.
The index: the document's version, its entries by path, and the paths
in document order.
ADR-0006 decision 4's closed type set. No floats anywhere: money is
integer minor units, and a decimal, a datetime and a duration are
all carried as strings (ADR-0001 decision 6).
Functions
Both projections in the shape
StatifierBlocks.Compiler.SensitivePaths.datamodel/1 normalizes, so a
host that has a document supplies one input rather than two.
Whether the document declares path.
ADR-0006 decision 6's projection: every entry's own path, at every
nesting depth, and nothing else.
Every entry, in document order.
The entry declared at path.
Admits a decoded ADR-0006 document and indexes it, or returns nil.
The declared paths the document annotates sensitive?: true (ADR-0006
decision 7, ADR-0002's sensitive? amendment).
The declared type of path, or nil when the document does not declare
it - unknown, never wrong (ADR-0006 decision 9).
Every entry strictly under prefix, at any depth, in document order -
the completion query.
Types
@type entry() :: %{ path: String.t(), name: String.t() | nil, type: type() | nil, label: String.t() | nil, scope: scope(), depth: non_neg_integer(), item_type: type() | nil, example: term(), note: String.t() | nil, one_of: [term()] | nil, sensitive?: boolean() }
One declared path, flattened out of the document.
depth is the nesting level fields reached it at - 0 for a
top-level entry - and is what a pane groups by when it renders an object
and its members together.
@type scope() :: :global | :local | :event | nil
ADR-0006 decision 5's three scopes, or nil for a scope map naming none of them.
@type t() :: %StatifierBlocks.Predicates.Datamodel{ entries: %{optional(String.t()) => entry()}, order: [String.t()], version: integer() }
The index: the document's version, its entries by path, and the paths
in document order.
@type type() ::
:string
| :integer
| :decimal
| :boolean
| :datetime
| :duration
| :object
| :list
ADR-0006 decision 4's closed type set. No floats anywhere: money is
integer minor units, and a decimal, a datetime and a duration are
all carried as strings (ADR-0001 decision 6).
A duration value - and the example beside it - is a duration string
the expression language reads, 30s or 1h30m, the same string an
author types into the :duration field this record's paths feed. That
is decision 4 as amended 2026-09-05 (clauses 4a and 4d). Which strings
parse is Predicator.Duration's to define; this module stores whatever
the document holds and parses none of it.
Functions
Both projections in the shape
StatifierBlocks.Compiler.SensitivePaths.datamodel/1 normalizes, so a
host that has a document supplies one input rather than two.
Nothing in this package calls it yet; it is the derivation the record promised, at the shape the pass that would consume it already takes.
Whether the document declares path.
A false here is this document does not declare it, which is the
input to 11e's :info advisory and to nothing that refuses anything.
ADR-0006 decision 6's projection: every entry's own path, at every
nesting depth, and nothing else.
An object entry contributes its own path and, recursively, its
fields'. A list entry contributes its own path alone - item_type
names an element type and no record decides an index syntax, so there is
no element path to contribute.
iex> alias StatifierBlocks.Predicates.Datamodel
iex> %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "risk_reasons", "type" => "list", "item_type" => "string"},
...> %{"path" => "card", "type" => "object", "fields" => [%{"path" => "card.brand"}]}]}]}
...> |> Datamodel.index()
...> |> Datamodel.declared_paths()
MapSet.new(["card", "card.brand", "risk_reasons"])
Every entry, in document order.
The entry declared at path.
iex> alias StatifierBlocks.Predicates.Datamodel
iex> index = Datamodel.index(%{"scopes" => [
...> %{"scope" => "local", "entries" => [%{"path" => "amount_cents", "type" => "integer"}]}]})
iex> {:ok, entry} = Datamodel.fetch(index, "amount_cents")
iex> entry.type
:integer
iex> Datamodel.fetch(index, "amount_dollars")
:error
Admits a decoded ADR-0006 document and indexes it, or returns nil.
Total: every input has an answer and none of them raise. A map carrying
a list under "scopes" is a document; anything else - a set, a list of
paths, a bare map, a number - is not one, and gets nil rather than an
empty index, so not a document stays distinguishable from a document
declaring nothing.
iex> alias StatifierBlocks.Predicates.Datamodel
iex> index = Datamodel.index(%{"version" => 1, "scopes" => [
...> %{"scope" => "local", "entries" => [
...> %{"name" => "card", "path" => "card", "type" => "object", "label" => "Card",
...> "fields" => [
...> %{"name" => "brand", "path" => "card.brand", "type" => "string",
...> "label" => "Brand"}]}]}]})
iex> index.order
["card", "card.brand"]
iex> Datamodel.type(index, "card.brand")
:string
iex> StatifierBlocks.Predicates.Datamodel.index(["card.brand"])
nil
The declared paths the document annotates sensitive?: true (ADR-0006
decision 7, ADR-0002's sensitive? amendment).
Read per entry and literally: an object marked sensitive does not
stamp its fields. See the moduledoc for why that is the matcher's job
rather than this one's.
iex> alias StatifierBlocks.Predicates.Datamodel
iex> %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "card.token_id", "type" => "string"},
...> %{"path" => "card.number", "type" => "string", "sensitive?" => true}]}]}
...> |> Datamodel.index()
...> |> Datamodel.sensitive_paths()
MapSet.new(["card.number"])
The declared type of path, or nil when the document does not declare
it - unknown, never wrong (ADR-0006 decision 9).
nil is also what a declared entry whose type is outside the closed
set of eight gets, for the same reason: this module reports what it can
name and claims nothing about the rest.
Every entry strictly under prefix, at any depth, in document order -
the completion query.
The entry at prefix itself is not among them; fetch/2 is how a
caller asks about the prefix. A caller wanting one level only filters
the result on depth.
iex> alias StatifierBlocks.Predicates.Datamodel
iex> index = Datamodel.index(%{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "card", "type" => "object", "fields" => [
...> %{"path" => "card.brand"}, %{"path" => "card.last4"}]},
...> %{"path" => "cardholder"}]}]})
iex> index |> Datamodel.under("card") |> Enum.map(& &1.path)
["card.brand", "card.last4"]