The path/type index over a datamodel document (ADR-0001), and the projections that record promises.
ADR-0001 decision 3 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, one_of and sensitive?. Every path is absolute and globally
addressable (an event entry spells its own event. prefix), and decision
7 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 7's projection.
Advisory, never a gate
Decision 12 is the stance this module is built to keep: an undeclared
path is unknown, not wrong. Nothing here returns a verdict, produces a
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. Which of this module's facts is an
error, which an advisory and which is silent is decided in a consumer's
own record, argued on its own consumers.
The admission step is index/1
Decision 7 states its totality over admitted documents: a malformed
input is a loader concern, rejected before that function is reached,
which is why it has no {:error, _} arm to explain. index/1 is that
loader. It is itself total - an input it cannot admit returns nil, on
the total-normalizer discipline the family uses everywhere - 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 7), 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 the projection'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 it normalizes tonil- unknown, on the same stance as an undeclared path - rather than being carried through as a 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 the projection does, so the question stays open rather than being answered by use.
Value kinds, and why they are a third projection
path_types/1 is decision 11: the index projected to the expression
language's own vocabulary of value kinds, which is a smaller and different
set from the document's nine types. integer and decimal both answer
:number there, because that is the distinction the expression language
draws; object has no kind at all and neither does a list whose element
type the document does not name, so those paths are simply absent.
Absence is the same stance the rest of this module takes: an editor handed the map treats a path it does not contain exactly as it treats every path today, which is the reason this is a projection and not a check. The map carries no labels, scopes or declarations - a consumer wanting those reads the index it was built from.
sensitive?, and why it is derived here rather than projected
The entry map is where a per-path annotation lives, in the family's optional-boolean convention, and decision 7's projection deliberately drops it: a consumer that needs to know whether a path is sensitive reads the document, never the set.
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 7 specifies. datamodel/1 pairs the
two into a %{declared: MapSet, sensitive: MapSet} map, so a host with a
document reaches a sensitive-path 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: a
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.
Summary
Types
One declared path, flattened out of the document.
One value kind in the expression language's vocabulary.
The record'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.
The closed type set. No floats anywhere: money is integer minor units,
and a decimal, a datetime, a date and a duration are all carried
as strings.
What one path projects to: a kind, a list of a kind, or an enumeration of the values an editor may draw as choices.
Functions
Both projections in one map, so a host that has a document supplies one input to a sensitive-path pass rather than two.
Whether the document declares path.
ADR-0001 decision 7'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 datamodel document and indexes it, or returns nil.
ADR-0001 decision 11's projection: path -> value kind, in the
expression language's own vocabulary.
The declared paths the document annotates sensitive?: true.
The declared type of path, or nil when the document does not declare
it - unknown, never wrong (decision 12).
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 kind() :: :string | :number | :boolean | :date | :datetime | :duration
One value kind in the expression language's vocabulary.
Six atoms, not nine: integer and decimal are both :number, and
object and list have no kind of their own - a list is spelled
{:list, kind} and an object is absent. :list is a value kind the
expression language names too, but only ever inside that tuple here,
since an element type the document does not give is not a list this
projection can describe.
@type scope() :: :global | :local | :event | nil
The record's three scopes, or nil for a scope map naming none of them.
@type t() :: %StatifierDatamodel.Index{ 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
| :date
| :object
| :list
The closed type set. No floats anywhere: money is integer minor units,
and a decimal, a datetime, a date and a duration are all carried
as strings.
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 a :duration field these paths feed. A date value is
an ISO-8601 calendar date, 2026-09-05, which is the spelling the
expression language's own date literal reads. Which strings parse is the
expression language's to define; this module stores whatever the document
holds and parses none of it.
These are the nine ADR-0001 decision 4 closes the set at: the eight the
origin record closed it at, plus date. date is a distinct type and
not a datetime because the expression language distinguishes them, and
a projection that collapsed the two would offer the wrong operators.
What one path projects to: a kind, a list of a kind, or an enumeration of the values an editor may draw as choices.
Functions
Both projections in one map, so a host that has a document supplies one input to a sensitive-path pass rather than two.
Nothing in this package calls it; it is the derivation the record promised, at the shape a 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 an advisory and to nothing that refuses anything.
ADR-0001 decision 7'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 StatifierDatamodel.Index
iex> %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "risk_reasons", "type" => "list", "item_type" => "string"},
...> %{"path" => "card", "type" => "object", "fields" => [%{"path" => "card.brand"}]}]}]}
...> |> Index.index()
...> |> Index.declared_paths()
MapSet.new(["card", "card.brand", "risk_reasons"])
Every entry, in document order.
The entry declared at path.
iex> alias StatifierDatamodel.Index
iex> index = Index.index(%{"scopes" => [
...> %{"scope" => "local", "entries" => [%{"path" => "amount_cents", "type" => "integer"}]}]})
iex> {:ok, entry} = Index.fetch(index, "amount_cents")
iex> entry.type
:integer
iex> Index.fetch(index, "amount_dollars")
:error
Admits a decoded datamodel 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 StatifierDatamodel.Index
iex> index = Index.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> Index.type(index, "card.brand")
:string
iex> StatifierDatamodel.Index.index(["card.brand"])
nil
@spec path_types(t() | term()) :: %{optional(String.t()) => value_kind()}
ADR-0001 decision 11's projection: path -> value kind, in the
expression language's own vocabulary.
Total, over anything: nil and a value that is not an index project to
the empty map, on the same reasoning index/1 returns nil for an
input it cannot admit.
Per entry, in the record's order:
a drawable
one_ofwins over the kind and gives{:one_of, values}. Drawable means every value can be rendered as a choice - a string, a number or a boolean. Aone_ofthat is empty, or that holds anything else, is not drawn and the entry falls back to its kind. The enumeration winning is decision 11's word and is not qualified by the entry's type, so anobjectcarrying a drawableone_ofis present with its values, where the same entry without one would be absent.stringis:string;integeranddecimalare both:number;booleanis:boolean;date,datetimeanddurationare themselves.a
listwhoseitem_typeis one of those is{:list, kind}.object, alistwith no usableitem_type, and an entry whose type is outside the closed set are absent from the map. Absence means unknown, not wrong.iex> alias StatifierDatamodel.Index iex> %{"scopes" => [%{"scope" => "local", "entries" => [ ...> %{"path" => "amount_cents", "type" => "integer"}, ...> %{"path" => "risk_reasons", "type" => "list", "item_type" => "string"}, ...> %{"path" => "card", "type" => "object", "fields" => [ ...> %{"path" => "card.brand", "type" => "string", ...> "one_of" => ["visa", "mastercard", "amex"]}]}]}]} ...> |> Index.index() ...> |> Index.path_types() %{ "amount_cents" => :number, "risk_reasons" => {:list, :string}, "card.brand" => {:one_of, ["visa", "mastercard", "amex"]} }
iex> StatifierDatamodel.Index.path_types(nil) %{}
The declared paths the document annotates sensitive?: true.
Read per entry and literally: an object marked sensitive does not
stamp its fields. See the moduledoc for why that is a matcher's job
rather than this one's.
iex> alias StatifierDatamodel.Index
iex> %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "card.token_id", "type" => "string"},
...> %{"path" => "card.number", "type" => "string", "sensitive?" => true}]}]}
...> |> Index.index()
...> |> Index.sensitive_paths()
MapSet.new(["card.number"])
The declared type of path, or nil when the document does not declare
it - unknown, never wrong (decision 12).
nil is also what a declared entry whose type is outside the closed
set 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 StatifierDatamodel.Index
iex> index = Index.index(%{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "card", "type" => "object", "fields" => [
...> %{"path" => "card.brand"}, %{"path" => "card.last4"}]},
...> %{"path" => "cardholder"}]}]})
iex> index |> Index.under("card") |> Enum.map(& &1.path)
["card.brand", "card.last4"]