StatifierBlocks.Predicates.Datamodel (StatifierBlocks v0.14.0)

Copy Markdown View Source

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 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 decision 6'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 (decision 4). A type or item_type outside the eight normalizes to nil - 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.
  • 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 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.

t()

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

entry()

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

scope()

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

ADR-0006 decision 5's three scopes, or nil for a scope map naming none of them.

t()

@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 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).

Functions

datamodel(index)

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

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.

declared?(index, path)

@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 11e's :info advisory and to nothing that refuses anything.

declared_paths(datamodel)

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

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

entries(datamodel)

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

Every entry, in document order.

fetch(datamodel, path)

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

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

index(document)

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

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

sensitive_paths(index)

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

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

type(index, path)

@spec type(t(), term()) :: type() | nil

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.

under(index, prefix)

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