# `StatifierDatamodel.Index`
[🔗](https://github.com/riddler/statifier_datamodel/blob/v0.7.1/lib/statifier_datamodel/index.ex#L1)

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: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 `path` is not a non-empty string contributes no
    path**, and its `fields` are still walked. That is the only place
    this module departs from the projection's literal code, which would
    put a `nil` in the set; a set with a `nil` in it is not a set of
    declared paths, and the record admits no such entry in the first
    place.
  * **The type set is closed, and a declaration name resolves beside
    it** (decision 4; decision 3 as amended 2026-09-06). A `type` or
    `item_type` is read as one of the nine first and then as a name the
    document's `types` key declares, which normalizes to
    `{:declared, name}`; a spelling that names neither normalizes to
    `nil` - unknown, on the same stance as an undeclared path - rather
    than being carried through as a type this package would then be
    rendering. A `type` that is not a string at all normalizes to `nil`
    on the same reading, an inline shape map included: the amendment
    gives an inline shape no document spelling, so a map here names
    nothing and is unknown like any other spelling that does.
  * **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.
  * **`name` is read and stored, and nothing here consumes it.** The
    record carries an open question on how an event entry spells it; this
    module reads `path` alone for every derivation, exactly as the
    projection does, so the question stays open rather than being
    answered by use.

## An entry typed by a declaration

Decision 3 as amended 2026-09-06 lets an entry's `type` - and a `list`
entry's `item_type` - name a declaration the document's `types` key
declares, instead of one of the nine. The reference is **nominal**: the
entry's type is `{:declared, name}`, not a copy of the declaration's
fields.

Resolution is the closed set first and then `types`, the same precedence
`StatifierDatamodel.Types.parse/2` uses for a declaration field, so a
document that declares a type called `"string"` does not shadow the
scalar. A spelling that names neither is `nil` - unknown, exactly as
before.

A declaration-typed entry then **expands**: it contributes its own path,
and beneath it one path per field of the declaration, spelled
`<entry path>.<field name>` at the entry's depth plus one, in the
declaration's field order, recursively for a field that itself names a
declaration. That is exactly what an inlined `object` entry does with its
`fields`, which is the point - a host that spells the object out and a
host that names the declaration get the same paths, so decision 7's
projection and decision 11's are unchanged in their own terms.

An expanded path carries what the field carries and nothing more: the
field's `type`, `item_type`, `label` and `one_of`, its `name`, and the
entry's `scope`. `example` and `note` are absent and `sensitive?` is
`false`, because a declaration field has no such keys and this module does
not invent them. An entry that names a declaration **and** carries
`fields` contributes both, with the entry's own written-out `fields`
ahead of the expansion, so a host that spells a path out gets the entry it
wrote rather than the one derived from the declaration - first occurrence
wins here as it does for a repeated path.

A cycle between declarations is a document a host can write, and it
discharges rather than recurring: a declaration already being expanded on
the same chain is not expanded again, so `index/1` stays total. That is
the discipline the read check already uses for a cyclic read.

A `list` entry still contributes its own path alone whatever its
`item_type` names: `item_type` names an element type, no record decides an
index syntax, and there is no element path to expand.

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

# `declared`

```elixir
@type declared() :: {:declared, String.t()}
```

A declared name used as a type: an entry's `type` or `item_type` may name
a declaration the document's `types` key declares (decision 3 as amended
2026-09-06), and the index carries the name rather than the declaration.

# `entry`

```elixir
@type entry() :: %{
  path: String.t(),
  name: String.t() | nil,
  type: entry_type() | nil,
  label: String.t() | nil,
  scope: scope(),
  depth: non_neg_integer(),
  item_type: entry_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.

# `entry_type`

```elixir
@type entry_type() :: type() | declared()
```

What an entry's `type` or `item_type` may be: one of the nine, or the name
of a declaration.

# `kind`

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

# `scope`

```elixir
@type scope() :: :global | :local | :event | nil
```

The record's three scopes, or `nil` for a scope map naming none of them.

# `t`

```elixir
@type t() :: %StatifierDatamodel.Index{
  declarations: StatifierDatamodel.Declarations.t(),
  entries: %{optional(String.t()) =&gt; entry()},
  order: [String.t()],
  version: integer()
}
```

The index: the document's `version`, its entries by path, the paths in
document order, and the declarations the `types` key indexes to.

The declarations are carried because resolving an entry's `type` needs
them (decision 3 as amended 2026-09-06); they are
`StatifierDatamodel.Declarations.from_document/1`'s answer over the same
document, so a caller holding an index never builds a second one.

# `type`

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

# `value_kind`

```elixir
@type value_kind() :: kind() | {:list, kind()} | {:one_of, [term()]}
```

What one path projects to: a kind, a list of a kind, or an enumeration of
the values an editor may draw as choices.

# `datamodel`

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

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.

# `declared?`

```elixir
@spec declared?(t(), term()) :: boolean()
```

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.

# `declared_paths`

```elixir
@spec declared_paths(t()) :: MapSet.t(String.t())
```

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"])

# `entries`

```elixir
@spec entries(t()) :: [entry()]
```

Every entry, in document order.

# `fetch`

```elixir
@spec fetch(t(), term()) :: {:ok, entry()} | :error
```

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

# `index`

```elixir
@spec index(term()) :: t() | nil
```

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

# `path_types`

```elixir
@spec path_types(t() | term()) :: %{optional(String.t()) =&gt; 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_of`** wins 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. A `one_of` that 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 an `object` carrying a drawable
    `one_of` is present with its values, where the same entry without one
    would be absent.
  * `string` is `:string`; `integer` and `decimal` are both `:number`;
    `boolean` is `:boolean`; `date`, `datetime` and `duration` are
    themselves.
  * a `list` whose `item_type` is one of those is `{:list, kind}`.
  * `object`, a `list` with no usable `item_type`, an entry whose type
    names a declaration, and an entry whose type names nothing at all
    are **absent from the map**. Absence means unknown, not wrong: a
    declaration-typed entry is a record, and a record is no more one of
    the expression language's kinds than an `object` is.

    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)
    %{}

# `sensitive_paths`

```elixir
@spec sensitive_paths(t()) :: MapSet.t(String.t())
```

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"])

# `type`

```elixir
@spec type(t(), term()) :: entry_type() | nil
```

The declared type of `path`, or `nil` when the document does not declare
it - unknown, never wrong (decision 12).

A declared entry answers one of the nine, or `{:declared, name}` where
its `type` names a declaration the document's `types` key carries
(decision 3 as amended 2026-09-06). `nil` is what an entry whose `type`
names neither gets - a spelling outside the closed set that no
declaration claims, and anything that is not a string at all - for the
same reason: this module reports what it can name and claims nothing
about the rest.

# `under`

```elixir
@spec under(t(), term()) :: [entry()]
```

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"]

---

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