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, with the kind each row reports. Five ways a redefinition narrows:
| Change | Verdict | Kind | Break |
|---|---|---|---|
| a field removed | breaking | :field_removed | {:field_removed, name} |
a field's type changed | breaking | :type_changed | {:type_changed, name} |
| a field optional -> required | breaking | :made_required | {:made_required, name} |
| a required field added | breaking | :required_added | {:required_added, name} |
| a field required -> optional | breaking | :made_optional | {:made_optional, name} |
and one way it does not:
| Change | Verdict | Kind |
|---|---|---|
| an optional field added | compatible | none - nothing is reported |
The kind is the first element
A break's first element is the row it came from: one atom per breaking row of the table above, and the same atom every time that row is the reason. An embedder wording a warning matches on it and never re-derives from the two declarations why the row broke.
case Compatibility.breaks(old, new) do
:error -> "nothing was redefined"
breaks -> Enum.map(breaks, fn
{:field_removed, name} -> "#{name} is gone"
{:type_changed, name} -> "#{name} holds a different type"
{:made_required, name} -> "#{name} must now be there"
{:required_added, name} -> "#{name} was added and must be there"
{:made_optional, name} -> "#{name} is no longer promised"
end)
endThe vocabulary is closed at those five and is the break() type: a case
that names all five is exhaustive over every break this module reports, and
a sixth kind would be a new row in the record's table, not a new spelling of
an old one. The compatible row reports nothing at all, so there is no kind
for an optional field added to match on - an empty list is the answer.
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
Types
One way a redefinition narrows what it replaces: the kind, which names the row of the record's table it came from, and the field it happened to.
Functions
Every way new narrows old, ordered by field name then by reason.
Types
@type break() :: {:field_removed, String.t()} | {:type_changed, String.t()} | {:made_required, String.t()} | {:required_added, String.t()} | {:made_optional, String.t()}
One way a redefinition narrows what it replaces: the kind, which names the row of the record's table it came from, and the field it happened to.
The kinds, in the table's order: :field_removed for a field removed,
:type_changed for a field's type changed, :made_required for a field
optional -> required, :required_added for a required field added, and
:made_optional for a field required -> optional. The vocabulary is closed
at those five.
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.
Each break names its kind first - :field_removed, :type_changed,
:made_required, :required_added or :made_optional, one per breaking
row of the record's table - and the field second, so a caller words a
warning by matching the kind rather than by comparing the two declarations
again.
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