StatifierDatamodel.Compatibility (StatifierDatamodel v0.3.0)

Copy Markdown View Source

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:

ChangeVerdictBreak
a field removedbreaking{:field_removed, name}
a field's type changedbreaking{:type_changed, name}
a field optional -> requiredbreaking{:made_required, name}
a required field addedbreaking{:required_added, name}
a field required -> optionalbreaking{:made_optional, name}

and one way it does not:

ChangeVerdict
an optional field addedcompatible

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, naming the field it happened to.

Functions

Every way new narrows old, ordered by field name then by reason.

Types

break()

@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, naming the field it happened to.

Functions

breaks(old, new)

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