StatifierBlocks.Datamodel (StatifierBlocks v0.15.0)

Copy Markdown View Source

The host's datamodel as the editor consumes it, and the one check it makes against it (ADR-0005 decision 11, amended 2026-08-29 as 11e-11g).

A block type may declare that one of its config fields holds a path into the host's datamodel - ADR-0002 decision 7's optional datamodel_path?: true key, amended the same day. This module is what reads that declaration: given a document, a palette and a datamodel, it returns one :info finding per annotated field whose value the datamodel does not declare, anchored {:config, block_id, key} with source :lint.

Deliberately pure, and outside StatifierBlocks.Editor.*: the editor's job is translation (ADR-0005 decision 1), so the rule lives here and is asserted with phoenix_live_view absent from the dependency tree.

Absence is not unknown-ness

11f states the qualifier as a condition on production, and so does this module: with no datamodel supplied, the check does not run. Not a quieter severity, not an empty pane, not an "unknown" row - nothing.

The reason is 11d's objection, which 11f answers rather than overrules: a host may legitimately carry values it has never described, so an undeclared-path claim is unfounded precisely when nothing was described. A host that hands the editor a datamodel is making the claim itself - it is saying these are the paths this document may address - and a path outside that set is then worth the author's attention, which is what :info means.

An empty declared set is not the same as no datamodel. A host that supplies [] has said its documents may address nothing, and every annotated path is then undeclared. That is a real claim and it is reported; only nil suppresses the check.

What counts as a datamodel

declared_paths/1 is the whole input contract, and it is deliberately small: a set of declared dotted paths. It accepts

  • nil - no datamodel, so no check;
  • a list of strings - the declared paths, blanks and non-strings dropped;
  • a MapSet of strings - the normalized form, idempotently;
  • a decoded datamodel document - sb ADR-0006's typed three-scope shape, of which spike/fixtures/datamodel.json is an instance - projected to its declared-path set.

It accepts nothing else. The document arm is the one 11f promised and sb-oiq built: ADR-0006 (accepted 2026-08-29) defines the shape and its decision 6 gives the projection, and StatifierBlocks.Predicates.Datamodel implements both, so this module reads a document through that one total function rather than growing a second reader of a schema. Nothing else here moved - the set is still the whole contract this check needs, which is what 11e says: "This section is written against the set, so it holds under either."

An empty document - one whose scopes declare no entries - projects to MapSet.new(), not to nil. Decision 6 states that distinction in the same words this module's own "absence is not unknown-ness" section does: a host that supplied a document declaring nothing has made a claim, and nil is reserved for the host that supplied no datamodel at all.

[Correction 2026-08-29, sb-l0g: this paragraph read "no accepted record defines that shape, it is filed as a Proposed record (sb-g8m) still being checked against statifier-ui's ADR-0006 datasets, and a normalizer written against it here would ship an unratified document schema as a side effect of a lint. When that record is accepted, the derivation is ...". The record landed: sb ADR-0006, "The datamodel document is a typed, three-scope declaration, and the declared-path set is its projection", accepted 2026-08-29 (PR 101), and the sui-ADR-0006 cross-check it was waiting on was done in that record. Stale status only. What this module accepts is unchanged - declared_paths/1 still takes exactly the three shapes listed above - and building ADR-0006's projection is separate work, not this correction's.]

[Note 2026-08-29, sb-oiq: the "separate work" that correction named is this one, and the list above now carries a fourth shape. The three sb-l0g left unchanged are unchanged still: the document arm is additive and reads ADR-0006's projection rather than a second schema of its own.]

Anything else - a map with no scopes list, a struct, a number - normalizes to nil, so a host that passes a shape this package does not know gets the behaviour it had before it passed anything, rather than a document suddenly covered in advisories. That is ADR-0002 amendment B3's total-normalizer discipline, reaching one more input.

What counts as declared (11k-11m)

A datamodel is not the only thing that declares. Two more surfaces do, and the 2026-08-31 amendment 11k folds them into the same set this check reads:

  • the compile call's :declare roots, which the editor takes as its declare assign because it has no compile call of its own to read;
  • the document's own datamodel key (ADR-0001 decision 11), which this module reads straight off the %StatifierBlocks.Document{} it is already handed - no plumbing, and no way for a caller to forget it.

Both name bare roots, so 11l matches them by root segment: a declared root signup declares signup and everything beneath it. The datamodel's own paths are unchanged and still match whole, because the two are different claims - a datamodel enumerates the paths a document may address, so a path it omits is a path it excluded; a root declaration says storage exists at a name and says nothing about what is under it.

11m widens 11f's precondition to match: the check runs when a datamodel was supplied or when either surface declares a root, and produces nothing at all when nothing anywhere was declared. Precedence does not reach here (11k): ADR-0001 11f's host-wins rule decides which <data> element is emitted for a colliding id, and a root shadowed under it is still a declared root.

What this check is not

It changes no verdict. A document whose only findings are these compiles exactly as it did before (11c), and any consumer gating on findings gates on :error as it always has.

It is also not validate_config/1's job and cannot be: StatifierBlocks.Core.Assign's moduledoc says why - that callback is handed a config, not a document, and "is this path declared?" is a question about something outside the block entirely. This is the document-level pass it names.

The same set, offered forwards (sb-0vt)

candidates/3 and candidates_under/2 read these very surfaces to answer the other question an author has about them: not "is the path I wrote declared?" but "what is there to write?". They are deliberately in this module and not in a new one, because the alternative is two readers of three surfaces that would drift - an editor could then offer a path it would immediately flag, or flag one it had just offered.

Offering is not evaluating and it is not editing. ADR-0005 decision 9 still keeps rich expression editing in statifier-ui behind the expression_component seam; what changed is that the seam, and the plain input the package ships beside it, are now handed the declared paths instead of each caller re-deriving them. See candidates/3 for what is deliberately absent from the list.

The uncommitted-draft gap, stated rather than discovered

The check reads the document, so an advisory follows a config that has validated and been committed. While the author has an uncommitted draft on a field (ADR-0005 decision 9's draft, held in the editor and never in the document), the form shows the draft's own findings and this advisory is not among them. Whether an advisory should be recomputed against a draft is a decision-9 question no record answers, so this module does not answer it either.

Summary

Types

The normalized datamodel: the set of paths the host declares, or nil when the host supplied none.

One row of the read-only declared-path view: a path, every surface that declared it, and whatever shape the ADR-0006 projection carries for it.

Which of the three declaring surfaces 11k names contributed a path.

Functions

The declared datamodel paths an expression control offers as candidates, sorted and deduplicated - the data half of sb-0vt, and nothing else.

The candidates strictly under prefix, in the datamodel document's own order - ADR-0006 decision 6's completion query, reached through StatifierBlocks.Predicates.Datamodel.under/2 rather than restated here.

Normalizes a host-supplied datamodel to a declared-path set, or nil.

Normalizes the compile call's :declare roots to a set of root names.

Every declared path, with the surfaces that declared it and the shape the ADR-0006 projection carries for it - the read-only view of what the 11e advisory reads.

Every undeclared-path advisory in the document (11e), or [] when nothing anywhere declared anything (11f as 11m widens it).

Types

declared()

@type declared() :: MapSet.t(String.t()) | nil

The normalized datamodel: the set of paths the host declares, or nil when the host supplied none.

declared_row()

@type declared_row() :: %{
  path: String.t(),
  sources: [source()],
  type: StatifierBlocks.Predicates.Datamodel.type() | nil,
  item_type: StatifierBlocks.Predicates.Datamodel.type() | nil,
  scope: StatifierBlocks.Predicates.Datamodel.scope() | nil,
  label: String.t() | nil,
  sensitive?: boolean()
}

One row of the read-only declared-path view: a path, every surface that declared it, and whatever shape the ADR-0006 projection carries for it.

type, item_type, scope, label and sensitive? come from the document entry at that path and are nil (false for the flag) when no entry describes it. A bare declared root is exactly that case, and 11l is why it is a row with no shape rather than no row: a root says storage exists at a name and says nothing about what is under it.

source()

@type source() :: :datamodel | :declare | :document

Which of the three declaring surfaces 11k names contributed a path.

  • :datamodel - the host's datamodel, a set or an ADR-0006 document;
  • :declare - the compile call's declared roots;
  • :document - the document's own datamodel key.

Functions

candidates(document, datamodel, declare \\ [])

@spec candidates(StatifierBlocks.Document.t(), term(), term()) :: [String.t()]

The declared datamodel paths an expression control offers as candidates, sorted and deduplicated - the data half of sb-0vt, and nothing else.

This reads the same three declaring surfaces findings/4 does, by the same normalizers, so the set an author is offered and the set that decides whether they get an advisory cannot drift apart: the host's datamodel through declared_paths/1, the compile call's roots through declared_roots/1, and the document's own datamodel key read straight off document, which is why that surface takes no argument here either.

Absence collapses to [] rather than to nil. That is a deliberate difference from the advisory above, and it is not a rescue-to-default: nil is load-bearing for findings/4 because "nobody described anything" and "somebody described nothing" are different claims about a path the author already wrote. A candidate list makes no claim about anything - it either has something to offer or it does not - so both cases are one empty list, and the caller renders no control for it.

A root and a path are both offered whole. A declared root means storage exists at a name and says nothing about what is under it (11l), so signup is the only candidate it can contribute; the datamodel document is what contributes signup.email.

What this is not

It is not the completion feature. ADR-0005 decision 9 keeps rich expression editing - completion against the datamodel as an affordance, inline evaluation against a dataset - in statifier-ui behind the expression_component seam, and this function does not move it. It supplies the one part of that affordance this package owns the data for, so the seam's implementer does not have to re-derive it and the shipped plain input can offer a <datalist> in the meantime.

Operators, keywords and literals are not here and cannot be yet. Predicator exposes no public enumeration of its grammar - operator and keyword tokens live inside Predicator.Lexer - and copying that vocabulary into this package would be a second, silently drifting copy of a contract predicator owns. That half is px-15q's.

iex> alias StatifierBlocks.{Block, Datamodel, Document}
iex> document = Document.new(Block.new("core.sequence", id: "blk_root"), id: "doc_x")
iex> Datamodel.candidates(document, ["card.brand", "card"], ["signup"])
["card", "card.brand", "signup"]

iex> alias StatifierBlocks.{Block, Datamodel, Document}
iex> document = Document.new(Block.new("core.sequence", id: "blk_root"), id: "doc_x")
iex> Datamodel.candidates(document, nil, [])
[]

candidates_under(datamodel, prefix)

@spec candidates_under(term(), term()) :: [String.t()]

The candidates strictly under prefix, in the datamodel document's own order - ADR-0006 decision 6's completion query, reached through StatifierBlocks.Predicates.Datamodel.under/2 rather than restated here.

This is the narrowing query the expression_component seam needs and the shipped <datalist> does not: a datalist is handed the whole set once and the browser filters it, while a component that re-renders per keystroke wants only the branch the author is inside. Both read the same document, through the same one implementation of the projection.

prefix itself is not among the results, matching under/2; a datamodel that is not an ADR-0006 document has no order to query and returns [], which is the same total-normalizer discipline declared_paths/1 applies to the same input.

iex> alias StatifierBlocks.Datamodel
iex> Datamodel.candidates_under(
...>   %{"scopes" => [%{"scope" => "local", "entries" => [
...>     %{"path" => "card", "type" => "object", "fields" => [
...>       %{"path" => "card.brand"}, %{"path" => "card.last4"}]}]}]},
...>   "card")
["card.brand", "card.last4"]

iex> StatifierBlocks.Datamodel.candidates_under(["card.brand"], "card")
[]

declared_paths(set)

@spec declared_paths(term()) :: declared()

Normalizes a host-supplied datamodel to a declared-path set, or nil.

Total: every input has an answer and none of them raise. See the moduledoc for what is accepted and why the list is short.

iex> StatifierBlocks.Datamodel.declared_paths(nil)
nil

iex> StatifierBlocks.Datamodel.declared_paths(["signup.step", "signup.variant_id"])
MapSet.new(["signup.step", "signup.variant_id"])

iex> StatifierBlocks.Datamodel.declared_paths(["signup.step", "", 42])
MapSet.new(["signup.step"])

iex> StatifierBlocks.Datamodel.declared_paths(%{"scopes" => []})
MapSet.new([])

iex> StatifierBlocks.Datamodel.declared_paths(%{"version" => 1, "scopes" => [
...>   %{"scope" => "local", "entries" => [
...>     %{"name" => "step", "path" => "signup.step", "type" => "string",
...>       "label" => "Step"}]}]})
MapSet.new(["signup.step"])

iex> StatifierBlocks.Datamodel.declared_paths(%{"scopes" => "not a list"})
nil

declared_roots(set)

@spec declared_roots(term()) :: MapSet.t(String.t())

Normalizes the compile call's :declare roots to a set of root names.

11k's second source. The input is the shape StatifierBlocks.Compiler.compile/3 takes - a list of {id, expr} pairs in declaration order - so a host passes the editor the same list it passes the compiler rather than a second spelling of it. A bare id is accepted too, and an already-normalized MapSet passes through idempotently.

Total, like declared_paths/1, and for the same reason: a shape this package does not know normalizes to the empty set, which per 11m declares nothing rather than declaring that nothing exists. There is no nil here - a root set is never a claim about paths, so it has no "no datamodel supplied" state to distinguish.

iex> StatifierBlocks.Datamodel.declared_roots([{"signup", nil}, {"card", "0"}])
MapSet.new(["card", "signup"])

iex> StatifierBlocks.Datamodel.declared_roots(["signup"])
MapSet.new(["signup"])

iex> StatifierBlocks.Datamodel.declared_roots(nil)
MapSet.new([])

iex> StatifierBlocks.Datamodel.declared_roots([{"signup", nil}, 42, {"", nil}])
MapSet.new(["signup"])

declared_view(document, datamodel, declare \\ [])

@spec declared_view(StatifierBlocks.Document.t(), term(), term()) :: [declared_row()]

Every declared path, with the surfaces that declared it and the shape the ADR-0006 projection carries for it - the read-only view of what the 11e advisory reads.

The rows are candidates/3's set, in candidates/3's order, and that is the point rather than a convenience: the advisory decides "is the path this author wrote declared?" against the union of 11k's three surfaces, so a view that showed only one of them would answer a different question than the one the author is looking at a finding about. Reading the three through the same normalizers here is the same anti-drift rule candidates/3 is written under.

sources is a list because the surfaces overlap legitimately: a host that declares a root and also enumerates it in its datamodel document has said the same thing twice, and a row that named only the first would hide the second.

Shape comes from the ADR-0006 document alone, through StatifierBlocks.Predicates.Datamodel.fetch/2, and is absent for everything else. A set of paths carries no types, and a declared root carries none by 11l, so those rows are shapeless rather than guessed at.

iex> alias StatifierBlocks.{Block, Datamodel, Document}
iex> document = Document.new(Block.new("core.sequence", id: "blk_root"), id: "doc_x")
iex> datamodel = %{"scopes" => [%{"scope" => "local", "entries" => [
...>   %{"path" => "card.brand", "type" => "string", "label" => "Brand"}]}]}
iex> Datamodel.declared_view(document, datamodel, ["signup"])
[
  %{path: "card.brand", sources: [:datamodel], type: :string, item_type: nil,
    scope: :local, label: "Brand", sensitive?: false},
  %{path: "signup", sources: [:declare], type: nil, item_type: nil,
    scope: nil, label: nil, sensitive?: false}
]

iex> alias StatifierBlocks.{Block, Datamodel, Document}
iex> document = Document.new(Block.new("core.sequence", id: "blk_root"), id: "doc_x")
iex> Datamodel.declared_view(document, nil, [])
[]

findings(document, palette, datamodel, declare \\ [])

Every undeclared-path advisory in the document (11e), or [] when nothing anywhere declared anything (11f as 11m widens it).

datamodel is normalized through declared_paths/1 and declare through declared_roots/1, so a raw list and an already-normalized set behave identically in both. The document's own roots are read off document and take no argument.

A path is declared when the datamodel holds it whole or when either declaration surface holds its root segment (11k, 11l).

A block whose type the palette cannot resolve produces nothing here - it already has a :resolution finding from StatifierBlocks.ViewModel, and a block with no schema declares no datamodel path. A field annotated datamodel_path?: true whose value is missing or is not a non-empty string produces nothing either: that is validate_config/1's to refuse, at :error, and an advisory beside it would say the same thing twice in two voices.