# `StatifierBlocks.Document`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/document.ex#L25)

A block document: one tree, one envelope (ADR-0001).

This module is the package's single public entry point over the tree:
construction, the shared pre-order walk, and path lookup live here.
Canonical encoding, content identity, and structural decoding land in
later phases of the same bead.

The document also carries its own `datamodel` key: a list of
`StatifierBlocks.Document.DatamodelEntry` structs naming the `<data>`
roots the document's own guards and assigns read (ADR-0001 decision 11).
It follows, never leads, the compile call's `:declare` option - see
`StatifierBlocks.Compiler`'s moduledoc for the precedence rule.

Beside it sits `accepts`: the names of the external events the document
accepts, in the author's order (ADR-0014). Here it is a declaration and
nothing more. An empty list and an absent key are the same declaration,
the compile carries the list onto the artifact without reading it, and
whether each name is one the chart can take is judged by a host's publish
step, not by this package.

`committed_config/2` and `effective_config/3` are the two config readers
a surface asks the document, public because the reference embedder's Plan
view - the first consumer of both - had written them out privately in
order to draw a config form over a document at all. They are lookups over
`blocks/1`: what the document says, and what a held draft says instead.

# `id`

```elixir
@type id() :: String.t()
```

`"bdoc_" <> uxid`.

# `path`

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

Derived, never stored. Identifies a position, not a block.

# `t`

```elixir
@type t() :: %StatifierBlocks.Document{
  accepts: [String.t()],
  datamodel: [StatifierBlocks.Document.DatamodelEntry.t()],
  id: id(),
  metadata: %{optional(String.t()) =&gt; StatifierBlocks.Block.json()},
  revision: non_neg_integer(),
  root: StatifierBlocks.Block.t(),
  schema_version: pos_integer()
}
```

# `validation_error`

```elixir
@type validation_error() ::
  :not_a_block_document
  | {:unsupported_schema_version, pos_integer()}
  | {:duplicate_block_id, StatifierBlocks.Block.id()}
  | {:malformed_block, StatifierBlocks.Block.id() | nil, term()}
  | {:malformed_envelope, term()}
```

ADR-0001's typespec block: everything `validate/1` and `from_json/1` can
reject, defined once.

# `blocks`

```elixir
@spec blocks(t()) :: [StatifierBlocks.Block.t()]
```

Every block in `document`, pre-order, root first.

Within a block, slots are visited in UTF-8-sorted slot-name order so
every consumer of this walk sees one deterministic order regardless of
how the `slots` map happened to be built. Later phases (encoding,
validation) build on this walk rather than re-deriving their own.

# `committed_config`

```elixir
@spec committed_config(t(), StatifierBlocks.Block.id()) ::
  StatifierBlocks.Block.config()
```

The config the document holds for the block carrying `id`, or `%{}` when
no block in `document` carries it.

A lookup over `blocks/1` and nothing else: what the document says, with
no draft, no palette and no schema anywhere near the answer. The first
consumer is the reference embedder's Plan view, which needs the
committed config in order to show what an editor's unaccepted draft would
change; the package's own editor asks the same question for the same
reason.

`%{}` for an absent block rather than `nil`, because every caller is
about to read config keys out of the answer and a block that is not there
has none of them - the same shape as a block that carries no config at
all.

    iex> alias StatifierBlocks.{Block, Document}
    iex> root =
    ...>   Block.new("core.sequence",
    ...>     id: "root",
    ...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
    ...>   )
    iex> document = Document.new(root)
    iex> Document.committed_config(document, "wait")
    %{"duration" => "30s"}
    iex> Document.committed_config(document, "absent")
    %{}

# `content_hash`

```elixir
@spec content_hash(t()) :: binary()
```

SHA-256 over `to_json/1`. Stable document identity.

# `effective_config`

```elixir
@spec effective_config(t(), StatifierBlocks.Block.id()) ::
  StatifierBlocks.Block.config()
```

`effective_config/3` for a caller holding no drafts: `committed_config/2`.

It exists so that a surface that has not grown drafts yet, and a test
that is not about them, need not invent an empty map in order to say
there are none.

    iex> alias StatifierBlocks.{Block, Document}
    iex> root =
    ...>   Block.new("core.sequence",
    ...>     id: "root",
    ...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
    ...>   )
    iex> Document.effective_config(Document.new(root), "wait")
    %{"duration" => "30s"}

# `effective_config`

```elixir
@spec effective_config(t(), StatifierBlocks.Block.id(), %{
  optional(StatifierBlocks.Block.id()) =&gt; StatifierBlocks.Block.config()
}) :: StatifierBlocks.Block.config()
```

The config a surface should act on for the block carrying `id`: the
draft `drafts` holds for it, else `committed_config/2`.

A draft is config the document refused, held so the author keeps their
keystrokes (ADR-0002 decision 9). Reading a form back, or offering a
value to a field control, has to start from the draft where one exists
or the author's second edit is applied to the value their first one
replaced.

`drafts` is the map a surface keeps of block id to unaccepted config.

    iex> alias StatifierBlocks.{Block, Document}
    iex> root =
    ...>   Block.new("core.sequence",
    ...>     id: "root",
    ...>     slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
    ...>   )
    iex> document = Document.new(root)
    iex> Document.effective_config(document, "wait", %{"wait" => %{"duration" => "48h"}})
    %{"duration" => "48h"}
    iex> Document.effective_config(document, "wait", %{})
    %{"duration" => "30s"}

# `fetch_path`

```elixir
@spec fetch_path(t(), StatifierBlocks.Block.id()) :: {:ok, path()} | :error
```

The path from the root to the block carrying `id`.

A path names the `{parent block id, slot name, index}` steps taken from
the root, so the root's own path is `{:ok, []}` - it has taken none.
Returns `:error` when no block in `document` carries `id`.

# `from_json`

```elixir
@spec from_json(binary()) :: {:ok, t()} | {:error, validation_error()}
```

Structural decode. Never consults the block-type registry (ADR-0001
decision 9); unknown `type` names decode successfully.

Decoding is total and ordered: bytes that are not a JSON object carrying
a `"schema_version"` key are `:not_a_block_document`; a recognizable
document that is wrong in a specific way gets an envelope-, block-, or
id-level arm instead. Nothing is rescued to a default and nothing raises.

An envelope key outside the known set (`id`, `revision`, `root`,
`schema_version`, `metadata`, `datamodel`, `accepts` - decision 11 added
`datamodel` and ADR-0014 decision 2 added `accepts`) is refused rather than silently dropped, the same discipline the
block-level decode already applies to an unrecognized block key.

A block's optional `note` (ADR-0001's Amendment of 2026-09-28, `2b`)
decodes when it is a string, and an absent one decodes to `""`; any other
value, an explicit `null` included, is refused as
`{:malformed_block, id, {:note, :not_a_string}}`.

# `new`

```elixir
@spec new(
  StatifierBlocks.Block.t(),
  keyword()
) :: t()
```

Wraps `root` in a document envelope.

Options: `:id` (default a freshly minted `StatifierBlocks.Id.document/0`),
`:revision` (default `0`), `:metadata` (default `%{}`), `:datamodel`
(default `[]`, a list of `StatifierBlocks.Document.DatamodelEntry`
structs - ADR-0001 decision 11), `:accepts` (default `[]`, a list of
event names - ADR-0014 decision 2). `:schema_version` is not an option -
decision 7 fixes it at `1` for this ADR's envelope.

# `to_json`

```elixir
@spec to_json(t()) :: binary()
```

Canonical JSON per ADR-0001 decision 8. Deterministic: sorted object keys,
no insignificant whitespace, empty `slots`/`config`/`metadata`/`datamodel`/
`accepts` omitted, no floats. An empty `accepts` is omitted for the reason
an empty `datamodel` is: every document written before the key existed
keeps its bytes, and so its `content_hash/1` (ADR-0014 decision 2). A
block's empty `note` is omitted for the same reason; a non-empty note is
in the bytes, so it changes `content_hash/1` while the compiled chart
stays the same (ADR-0001's Amendment of 2026-09-28, `2b` and `2f`).

Runs `validate/1` first and raises `ArgumentError` carrying the validation
reason when it fails, so an invalid document can never produce bytes that
claim to be canonical.

# `validate`

```elixir
@spec validate(t()) :: :ok | {:error, validation_error()}
```

Checks `document` against ADR-0001's structural rules: schema version,
envelope shape (including the `datamodel` key's own entry shape and id
uniqueness - decision 11 - and the `accepts` list's four refusals -
ADR-0014 decision 2), per-block shape (id, type, type_version,
config, slots), and document-wide id uniqueness. Never consults a
block-type registry - `config` is opaque here and `type` is never
resolved against anything.

---

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