# `StatifierBlocks.Environment`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/environment.ex#L1)

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
`t: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.

# `annotated`

```elixir
@type annotated() :: %{
  optional(String.t()) =&gt;
    {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`

```elixir
@type context() :: %{
  optional(:entry_type) =&gt; type_expr(),
  optional(:datamodel) =&gt; term(),
  optional(:skip_blocks) =&gt; 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`

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

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

# `signature`

```elixir
@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`

```elixir
@type t() :: %{optional(String.t()) =&gt; type_expr()}
```

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

# `target`

```elixir
@type target() ::
  {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(),
   non_neg_integer()}
```

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

# `type_expr`

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

A type at a path. One of `t: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
`t:StatifierDatamodel.Types.t/0`.

# `annotated`

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

# `at`

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

# `declarations`

```elixir
@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`

```elixir
@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("registration.patron")
    :unknown

# `read_signatures`

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

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`

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

# `seed`

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

# `subject_path`

```elixir
@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`

```elixir
@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 "Patron
application" 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" => "myapp.patron_application", "kind" => "record",
    ...>     "label" => "Patron application", "fields" => []}]})
    iex> Environment.type_label(declarations, "myapp.patron_application")
    "Patron application"
    iex> Environment.type_label(declarations, "myapp.library_card")
    "myapp.library_card"
    iex> Environment.type_label(declarations, :unknown)
    "unknown"
    iex> Environment.type_label(declarations, {:list, "myapp.patron_application"})
    "list of Patron application"

# `type_of`

```elixir
@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}]}

# `with_writes`

```elixir
@spec with_writes(
  StatifierBlocks.Palette.t(),
  StatifierBlocks.Document.t(),
  StatifierBlocks.Block.t(),
  annotated(),
  StatifierDatamodel.Declarations.t()
) :: annotated()
```

`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 the downstream check inside
`StatifierBlocks.Assignability.check/5`. 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`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
