StatifierBlocks.Environment (StatifierBlocks v0.26.0)

Copy Markdown View Source

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}} writes T at the path the field's value names, and - where T names a record or a shape, or is an inline shape - one further entry per member of T beneath that path, recursively (the Amendment of 2026-09-07, and "Member expansion" below);
  • a {:path, opts} field with no writes key, and a field carrying datamodel_path?: true, write :unknown there - the path becomes known without becoming typed;
  • a capture config map writes :unknown at each of its keys, one per pair;
  • {:path, %{expects: T}} reads T there.

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.

t()

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.

env with block's own writes applied - what the block after it sees.

Every write block declares, in config_schema/1 order, then its capture pairs, then the produces sugar (ADR-0011 decision 2).

Types

annotated()

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

context()

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

member()

@type member() :: %{name: String.t(), type: type_expr(), required?: boolean()}

One member of an inline shape, as a spelling carries it.

signature()

@type signature() :: {key :: String.t() | atom(), path :: String.t(), type_expr()}

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.

t()

@type t() :: %{optional(String.t()) => type_expr()}

Datamodel path to type. ADR-0011 decision 1's environment.

target()

A position, as ADR-0001 decision 5 defines one.

type_expr()

@type type_expr() ::
  String.t() | :unknown | {:list, type_expr()} | {:shape, [member()]}

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

annotated(palette, document, target, ctx \\ %{})

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.

at(palette, document, target, ctx \\ %{})

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.

declarations(ctx)

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

inline_shape(members)

@spec inline_shape(term()) :: type_expr()

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

read_signatures(palette, document, block)

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

A composite answers here with the union of its expansion's reads, taken at its one position in the document: see expansion_signatures/5.

satisfies(declarations, held, expected)

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.

seed(palette, document, ctx \\ %{})

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.

subject_path(palette, document)

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

type_label(declarations, spelling)

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

type_of(declarations, spelling)

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

with_writes(palette, document, block, env, declarations \\ %{})

env with block's own writes applied - what the block after it sees.

One codepath for one rule (ADR-0011's Amendment of 2026-09-07, section 4): the walk applies a block's writes with this function, and so does the editor's drop-check preview through StatifierBlocks.Assignability.downstream_findings/6. For any block block, environment env and declarations declarations, the entries the preview holds after block and the entries the walk holds after block are the same map.

Three things happen per write signature, in this order. The members the previous write at the path derived are cleared, because a stale member entry beneath a path that no longer holds a record would be a claim nobody is making. The signature's own entry is put, annotated with block.id. Then the members its type derives are put, skipping every path this block writes explicitly - the explicit signature wins over the derived member, whichever order the two are declared in.

block's slots are not walked: what its subtree writes is the walk's business once the document holds it.

declarations is what the member expansion is read from (declarations/1 answers it for a context); it defaults to the empty index, under which a record-typed write puts its own entry and derives no member.

write_signatures(palette, document, block)

Every write block declares, in config_schema/1 order, then its capture pairs, then the produces sugar (ADR-0011 decision 2).

A composite answers here with the union of its expansion's writes, taken at its one position in the document: see expansion_signatures/5.