ADR-0001 decision 9: what a redefined declaration takes away from the one it replaces.
breaks/2 is given the declaration a name had and the declaration
replacing it, and lists every way the new one narrows the old one -
every reason a read that held under the old declaration might not hold
under the new. The list is empty when the redefinition takes nothing away,
and a name declared on neither side is an :error rather than an empty
list: nothing was redefined, so there is nothing to answer.
The six rows
The record's table, kept literally. Five ways a redefinition narrows:
| Change | Verdict | Break |
|---|---|---|
| a field removed | breaking | {:field_removed, name} |
a field's type changed | breaking | {:type_changed, name} |
| a field optional -> required | breaking | {:made_required, name} |
| a required field added | breaking | {:required_added, name} |
| a field required -> optional | breaking | {:made_optional, name} |
and one way it does not:
| Change | Verdict |
|---|---|
| an optional field added | compatible |
The asymmetry is the whole point: this answers may a reader keep reading, not did anything change. A declaration that gains an optional field takes nothing away from a read written against the old one.
Why relaxing a field is a break
A record field that goes required -> optional stops promising its value,
and the read check stopped reading an optional record field as covering a
required shape field when decision 8 was amended on 2026-09-06. A read that
held under the old declaration therefore does not hold under the new, which
is the one question this module answers. The row was compatible while step
3 ignored the record side's required?; the amendment of 2026-09-06 to
decision 9 moves it.
A one_of is a completion hint, never a break
A field's one_of lists the values a host expects and an editor draws as
choices. It is not a constraint: a value control fed from it still admits
anything the author types, and nothing in this package reads it as a
promise. Adding one, removing one, reordering one, widening one and
shrinking one are therefore all compatible, and the key means on a
declaration field exactly what it means on an entry (decision 9 as amended
2026-09-06).
What is not a break
A field's item_type is not compared, for the same reason the read check
does not descend into it: list satisfies list, and narrowing that is a
decision no record has taken. A changed label, a changed note, a
changed one_of and a changed kind are not among the record's five
either - label and note carry no contract, one_of is a hint, and a
name that changes kind is a redefinition this vocabulary has no word for.
Nothing here raises: an input that is not a declaration is read as not
declared on that side.
Deterministic order
Breaks are ordered by field name, then by reason in the order the record's table lists them, so two runs over the same pair produce the same list and a caller may compare two lists directly. One field can carry several breaks: a field that changed type and was relaxed at once names both.
Summary
Functions
Every way new narrows old, ordered by field name then by reason.
Types
Functions
@spec breaks( StatifierDatamodel.Declarations.declaration() | nil, StatifierDatamodel.Declarations.declaration() | nil ) :: [break()] | :error
Every way new narrows old, ordered by field name then by reason.
nil on a side means the name is not declared there: a declaration that
went away removes all of its fields, and one that appeared adds all of its
required ones. A name declared on neither side is :error.
iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "label" => "Card", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true},
...> %{"name" => "last4", "type" => "string", "required?" => true}]}]})
iex> now = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "label" => "Card", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true},
...> %{"name" => "expires_on", "type" => "date", "required?" => true}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.card")
iex> {:ok, new} = Declarations.fetch(now, "cards.card")
iex> Compatibility.breaks(old, new)
[{:required_added, "expires_on"}, {:field_removed, "last4"}]A redefinition that only adds an optional field takes nothing away, and neither does one that only edits a completion hint:
iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true,
...> "one_of" => ["visa", "mastercard", "amex"]}]}]})
iex> now = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true,
...> "one_of" => ["visa"]},
...> %{"name" => "last4", "type" => "string"}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.card")
iex> {:ok, new} = Declarations.fetch(now, "cards.card")
iex> Compatibility.breaks(old, new)
[]Relaxing a required field does take something away: the record stops promising the value, so a shape that requires it stops being covered.
iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true}]}]})
iex> now = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => false}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.card")
iex> {:ok, new} = Declarations.fetch(now, "cards.card")
iex> Compatibility.breaks(old, new)
[{:made_optional, "brand"}]
iex> StatifierDatamodel.Compatibility.breaks(nil, nil)
:error