StatifierBlocks.Datamodel (StatifierBlocks v0.3.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 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 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.

Functions

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

Every undeclared-path advisory in the document (11e), or [] when the host supplied no datamodel (11f).

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.

Functions

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

findings(document, palette, datamodel)

Every undeclared-path advisory in the document (11e), or [] when the host supplied no datamodel (11f).

datamodel is normalized through declared_paths/1, so a raw list and an already-normalized set behave identically.

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.