What is known at a position: a map from datamodel path to type, carried through the document by a pre-order walk (ADR-0011 decision 1).
Nothing flows between adjacent blocks. Every value a block produces is written to a datamodel path by name and every value it reads is read from one, so the data-flow question at a position is a question about the paths the document has written on the way there - not about the block before it.
The type of a type
A type is one of the nine the datamodel document closes its set at, the
name of a record or shape that document declares, an inline unnamed
shape as {:shape, members}, an opaque string a host carries,
{:list, type}, or :unknown. A named type arrives from a block's own
declaration or from the datamodel document; an inline shape has no
document syntax at all and arrives from a record's own decision, which
the fan-out envelope is today the only instance of. type_of/2 reads a
declaration's spelling into
StatifierDatamodel.Types.t/0 and satisfies/3 hands the pair to
StatifierDatamodel.Types.satisfies/3, which is the read check
(ADR-0011 decision 3). There is no second one here, and no Compatibility
or Coverage module of this package's own.
The string "unknown" reads as :unknown rather than as an opaque string
spelled that way, so that StatifierDatamodel.Types.to_string/1 and
type_of/2 are inverse over the whole grammar. It is the one
reinterpretation of an expression a host could already have been carrying,
and it only ever admits: an opaque "unknown" compared by identity was
already satisfied against another "unknown", so nothing that passed
before is refused now.
The walk
Document.blocks/1's pre-order, carrying the environment forward. For a
block reached with environment env:
- each slot the block carries is walked from
env- the shelf is not entered (ADR-0003's amendment of 2026-08-31, A2), and each parked fragment is walked from an empty environment instead; - what the slots produce is merged per path by agreement (decision 4):
a path every slot holds at one type keeps it, a path some hold and
others do not - or hold differently - drops to
:unknown, and a path no slot holds is absent; - the block's own write signatures are applied to the merge.
A container with one slot merges to that slot's own answer, so a group
body's writes leave the group. A core.on_event in a group's interrupt
slot writes its captures on that arm alone, so a path only it holds leaves
the group at :unknown - decision 10's answer, arrived at by decision 4
rather than by a special case.
Why the walk terminates
It descends. at/3 asks StatifierBlocks.Document.fetch_path/2 for the
path from the root to the position it was given, and then walks down
that path once, carrying the environment: each step folds the siblings
before it and moves to a strictly deeper block, and the path is finite
because the document is a finite tree. Nothing here asks an ancestor for
its own position, so there is no recursion to bound and a document built of
nothing but empty sequences costs one step per level - which is also why
the answer stays cheap enough for the editor to compute on mousedown.
Signatures
A block declares what it reads and writes on its fields, not on itself (ADR-0011 decision 2), so a finding anchors on the field key the author has to change:
{:path, %{writes: T}}writesTat the path the field's value names, and - whereTnames arecordor ashape, or is an inline shape - one further entry per member ofTbeneath that path, recursively (the Amendment of 2026-09-07, and "Member expansion" below);- a
{:path, opts}field with nowriteskey, and a field carryingdatamodel_path?: true, write:unknownthere - the path becomes known without becoming typed; - a
captureconfig map writes:unknownat each of its keys, one per pair; {:path, %{expects: T}}readsTthere.
Member expansion
A write of a record or a shape says what lives beneath the path as well as
at it (the Amendment of 2026-09-07). A write signature at P whose written
type names a record or shape declaration, or is an inline
{:shape, members}, puts an entry at P and an entry at P.m for
every member m, at the member's own type, joined with the same dot the
projection uses. A member that is itself a record or a shape expands again,
to any depth; a declaration already being expanded on the same chain of
paths contributes its entry and expands no further, so a self-referencing
declaration is finite. Nothing expands through a {:list, T} in either
direction - there is no element path to put an entry at.
A member entry is the weaker source. An explicit write signature at P.m
in the same block wins over the member derived from P's type, and a
rewrite at P clears the members its own previous write derived - never a
member entry an explicit signature wrote - and derives the new type's,
which for a scalar is none. A read a member entry refuses anchors on the
key of the field that declared the write at the root, because a
member entry is derived and has no control of its own.
The seed is untouched: a declared record already reaches the environment as
member entries through StatifierDatamodel.Index.entries/1, and this is
the same expansion said on the write side.
io/1's consumes and produces are sugar over the subject path,
which the entry block's palette entry names with subject: (decision 6):
consumes is a read there and produces is a write there. A document whose
entry block declares no subject has no subject path, and the sugar is inert.
A produces of :unknown writes nothing rather than blanking the subject -
a container that says nothing about the subject leaves it alone, which is
the whole gain decision 4's per-path merge exists for.
Summary
Types
The environment with the block that wrote each entry, which is what
ADR-0011 decision 8's upstream_ref names. :slot_entry is the seed, or a
merge whose arms agreed on a type without agreeing on who put it there,
and :declaration is the datamodel document itself - the writer of an
entry the seed took from the document's own declared path types.
Caller-supplied, not stored in the document. :datamodel is the datamodel
document the declarations are read from; :entry_type seeds the subject
path for a document whose entry block declares no produces of its own;
:skip_blocks names blocks whose own declared writes the walk leaves out.
One member of an inline shape, as a spelling carries it.
One declared read or write: the field key it is declared on (or
:consumes / :produces for the sugar, and :capture for a capture
pair), the path it names, and the type.
Datamodel path to type. ADR-0011 decision 1's environment.
A position, as ADR-0001 decision 5 defines one.
A type at a path. One of StatifierDatamodel.Types.t/0's inhabitants as
a document spells it - a scalar name, a declared name, an opaque string -
or :unknown, or a list of one of those, or an inline unnamed shape.
Functions
at/3, keeping the block that wrote each entry.
The environment at target, as a map from datamodel path to type.
The declarations ctx[:datamodel] carries, or an empty index.
Reads a stored member list into an inline shape (ADR-0002 decision 7, amended 2026-09-06).
Every read block declares, in config_schema/1 order with the
consumes sugar last.
The read check: StatifierDatamodel.Types.satisfies/3 over the two
spellings, and nothing else (ADR-0011 decision 3).
The environment the document opens with (ADR-0011 decision 2, amended 2026-09-06).
The datamodel path the document's subject lives at, or nil.
How a type expression is written for a human (ADR-0011 decision 9).
Reads a declared spelling into a type expression.
Every write block declares, in config_schema/1 order, then its capture
pairs, then the produces sugar (ADR-0011 decision 2).
Types
@type annotated() :: %{ optional(String.t()) => {type_expr(), StatifierBlocks.Block.id() | :slot_entry | :declaration} }
The environment with the block that wrote each entry, which is what
ADR-0011 decision 8's upstream_ref names. :slot_entry is the seed, or a
merge whose arms agreed on a type without agreeing on who put it there,
and :declaration is the datamodel document itself - the writer of an
entry the seed took from the document's own declared path types.
The two non-block writers differ in what an author would change. Neither
earns a {:fixable_by, block_id} reason, because neither is a block; a
:declaration says in as many words that the host's document typed the
path, which is the difference between "your block writes the wrong type
here" and "the host declares this path as something else".
@type context() :: %{ optional(:entry_type) => type_expr(), optional(:datamodel) => term(), optional(:skip_blocks) => MapSet.t(StatifierBlocks.Block.id()) }
Caller-supplied, not stored in the document. :datamodel is the datamodel
document the declarations are read from; :entry_type seeds the subject
path for a document whose entry block declares no produces of its own;
:skip_blocks names blocks whose own declared writes the walk leaves out.
:skip_blocks is how a caller that has already refused a block's config
keeps that block's declarations out of the answer: a write signature is
read off a config, so a config the compiler has already refused cannot be
trusted to say what the block writes, and an entry derived from one would
make the next block's read disagree with a type nobody declared. The
block's subtree still contributes - a child's config is its own - and
every other position walks exactly as it did. Absent, as it is for every
editor query, nothing is skipped.
One member of an inline shape, as a spelling carries it.
One declared read or write: the field key it is declared on (or
:consumes / :produces for the sugar, and :capture for a capture
pair), the path it names, and the type.
Datamodel path to type. ADR-0011 decision 1's environment.
@type target() :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()}
A position, as ADR-0001 decision 5 defines one.
A type at a path. One of StatifierDatamodel.Types.t/0's inhabitants as
a document spells it - a scalar name, a declared name, an opaque string -
or :unknown, or a list of one of those, or an inline unnamed shape.
The inline arm is sd-ADR-0001's, cited rather than respelled here: its
members carry exactly name, type and required?, member order is
authoring order, identity is member-set-wise, and a member's type is
never absent. A member's type is a spelling in this module's own
vocabulary, so the arm recurses and a member may hold an inline shape of
its own; type_of/2 is where the whole term is read into
StatifierDatamodel.Types.t/0.
Functions
@spec annotated( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), context() ) :: annotated()
at/3, keeping the block that wrote each entry.
ADR-0011 decision 8's {:type_mismatch, ...} names the block whose write
signature the read disagrees with, and this is where that name comes from.
@spec at( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), context() ) :: t()
The environment at target, as a map from datamodel path to type.
target is a position {parent_id, slot, index}: the answer is what the
block at that index sees, before its own writes are applied. Total - a
parent no block carries, or an index past the end of a slot, answers with
whatever the walk reached, never a raise.
@spec declarations(context()) :: StatifierDatamodel.Declarations.t()
The declarations ctx[:datamodel] carries, or an empty index.
StatifierDatamodel.Declarations.from_document/1 is total: a datamodel
that is not a document, or one with no types key, declares nothing rather
than failing, and a walk over it produces :unknown and not an exception.
Reads a stored member list into an inline shape (ADR-0002 decision 7, amended 2026-09-06).
A {:type_expr, opts} field stores its inline arm as JSON: a list of
objects each carrying "name", "type" and the optional boolean
"required?". This is where those bytes become the arm decision 1
admits, and it is the only direction that exists - an inline shape has no
document syntax, so nothing parses one out of a binary and
StatifierDatamodel.Types.parse/2 never returns one.
Member well-formedness is sd-ADR-0001's and is applied rather than
restated: a member whose name is not a non-empty string contributes
nothing, a repeated member name keeps its first occurrence, and a
member's type is never absent - a spelling that resolves to nothing is
the datamodel's unknown. Member order is the order the author wrote,
because that is the order an unmet-member reason renders in.
Total: anything that is not a list is :unknown.
iex> alias StatifierBlocks.Environment
iex> Environment.inline_shape([
...> %{"name" => "index", "type" => "integer", "required?" => true},
...> %{"name" => "note", "type" => "string"}])
{:shape, [
%{name: "index", type: "integer", required?: true},
%{name: "note", type: "string", required?: false}
]}
iex> StatifierBlocks.Environment.inline_shape("cards.settlement")
:unknown
@spec read_signatures( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t() ) :: [signature()]
Every read block declares, in config_schema/1 order with the
consumes sugar last.
A block with three path fields declares three reads and they are independent (ADR-0011 decision 2).
@spec satisfies(StatifierDatamodel.Declarations.t(), term(), term()) :: StatifierDatamodel.Types.reason()
The read check: StatifierDatamodel.Types.satisfies/3 over the two
spellings, and nothing else (ADR-0011 decision 3).
The palette's host relation is not asked here. It runs last, after this
returns not-satisfied, in StatifierBlocks.Assignability.assignable?/4.
@spec seed(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), context()) :: t()
The environment the document opens with (ADR-0011 decision 2, amended 2026-09-06).
Two sources, and the second is applied over the first:
- every path
ctx[:datamodel]declares, at the type it declares there, written by the document rather than by any block; ctx[:entry_type]at the subject path. It is ADR-0003 decision 4's context key, kept meaning what it meant - the type entering the document - now that the document has a path to hold it at.
A document with no entry block, or one whose palette entry declares no
subject:, seeds no subject however the context is filled: with no
subject path there is nowhere for a subject type to be.
A type the document's blocks write still wins, by position and with no new rule: a seeded entry is an entry like any other, so decision 1's last-write-wins settles the disagreement and a block writing a path replaces what the declaration seeded there for every position after it.
A read at a declared path is now checked. Before this it was a read
of a path the environment did not hold, which is decision 5's :info;
where the host declared the path and the document disagrees with it, it
is decision 5's :error. A caller that supplies no :datamodel seeds
nothing new and is unaffected in every particular.
The entry block's own writes are not applied here. Decision 2 says the document opens with its subject path holding its subject type, and the walk is what puts it there: the entry block is the first position walked, so every position after it sees exactly that. Applying its writes ahead of the walk would additionally put them in front of the entry block's own reads, and decision 1 is explicit that a block's reads are checked before its own writes are applied - a block does not read what it is about to write.
@spec subject_path(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t()) :: String.t() | nil
The datamodel path the document's subject lives at, or nil.
ADR-0011 decision 6: the subject: key on the entry block's
palette_entry/0 - the first block of the root's body slot. nil means
the document has no subject, and consumes and produces desugar to
nothing at all.
@spec type_label(StatifierDatamodel.Declarations.t(), term()) :: String.t()
How a type expression is written for a human (ADR-0011 decision 9).
A spelling that names a declaration in declarations renders that
declaration's label, which is the human-readable name the record asks a
finding and the Datamodel tab to carry so an author reads "Credit card
transaction" instead of a nominal name they have to go and look up. Every
other spelling renders exactly as it did before there were declarations:
one of the nine scalars as its own word, an opaque string a host carries as
itself, :unknown as unknown, and a list as what it holds.
It is a rendering and nothing else. No verdict reads it, nothing
branches on it, and a declaration whose label is absent renders its name
so a document that declares types without labelling them shows exactly what it showed before, rather than a blank where a name used to be.
iex> alias StatifierBlocks.Environment iex> declarations = StatifierDatamodel.Declarations.from_document(%{"types" => [ ...> %{"name" => "cards.credit_txn", "kind" => "record", ...> "label" => "Credit card transaction", "fields" => []}]}) iex> Environment.type_label(declarations, "cards.credit_txn") "Credit card transaction" iex> Environment.type_label(declarations, "myapp.card_txn") "myapp.card_txn" iex> Environment.type_label(declarations, :unknown) "unknown" iex> Environment.type_label(declarations, {:list, "cards.credit_txn"}) "list of Credit card transaction"
@spec type_of(StatifierDatamodel.Declarations.t(), term()) :: StatifierDatamodel.Types.t()
Reads a declared spelling into a type expression.
Total, and it defers to StatifierDatamodel.Types.parse/2 for everything
the grammar already covers. Three readings are this package's own: the
atom :unknown and the string "unknown" are both :unknown, a
{:list, _} is the document's own list - ADR-0011 decision 14 puts no
cardinality on a read, so a list is checked as a list and its item type is
carried for a fan-out to bind, not for the check to descend into - and an
inline shape is built here rather than parsed there. That package
reads a binary spelling and has no syntax for a shape, so a member's own
type is read by this function recursively and the members are handed over
as the term StatifierDatamodel.Types.satisfies/3 takes.
iex> alias StatifierBlocks.Environment
iex> Environment.type_of(%{}, {:shape, [
...> %{name: "index", type: "integer", required?: true}]})
{:shape, [%{name: "index", type: :integer, required?: true}]}
@spec write_signatures( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t() ) :: [signature()]
Every write block declares, in config_schema/1 order, then its capture
pairs, then the produces sugar (ADR-0011 decision 2).