Which units a CHANGE affects, from the diff of that change.
A :group claim answers "which unit of mine does this changed row belong to".
Answering it by READING the row works for an update in place and fails for the
two cases that matter:
- the row was deleted — nothing to read, so the propagation degrades to
"*": reprice the whole cell, because a vanished row might have left any group; - the row moved between units — the live row names where it landed, never
where it came from, so the group it left is stranded and the safe answer is
again
"*".
A version carries both sides. So the same question becomes arithmetic on a map, with no query and nothing to fail:
units(version) = grain(from) ∪ grain(to)See docs/adr-004-changes-as-the-propagation-source.md.
The diff shape
One entry per attribute, in exactly four shapes:
%{"to" => value} # a create: there was no prior value
%{"from" => old, "to" => new} # the attribute moved
%{"unchanged" => value} # present, untouched
# …and an attribute absent from the map entirely was not acceptedBorrowed from ash_paper_trail's change_tracking_mode :full_diff
(AshPaperTrail.ChangeBuilders.FullDiff.Helpers) rather than invented. A host
already keeping a paper trail speaks this, and a claim should not depend on
which producer wrote the change — today that is Payload's own write and the
dirties_on hook.
before/1 and after/1 project those into two plain maps, which is all a
grain function needs — Declarative.group_fn/1 reads with Map.get/2, so a
projected map substitutes for a row.
Why both sides, always
A :destroy has no to; a :create has no from. An update has both, and
they are the SAME unit unless the change touched a grain attribute — in which
case they are two, and both need repricing. Taking the union covers all four
without a branch per action type, and version_action_type is then a fact for
a human reading the log rather than a switch this module dispatches on.
Summary
Types
A :full_diff changes map: attribute name (as a string) to entry.
One attribute's entry in a :full_diff changes map.
Functions
The row as it is, as a plain map — nil for a destroy.
The row as it was, as a plain map — nil for a create.
The units a version affects, as the GROUP'S OWN VALUES — a tuple for a
composite grain, a bare value for a single one. units/3 is this, serialized.
The units a version affects, given the consumer's grain.
Types
Functions
The row as it is, as a plain map — nil for a destroy.
The row as it was, as a plain map — nil for a create.
Keys are ATOMS, because a grain declaration names attributes as atoms and a
version's changes names them as strings. Converting here keeps that
translation in one place instead of at every grain function.
The units a version affects, as the GROUP'S OWN VALUES — a tuple for a
composite grain, a bare value for a single one. units/3 is this, serialized.
The values are what a consumer actually wants. A fold scoping its read needs
fund == "gf" and fiscal_year == "2025", and from a joined "gf|2025" it can
only split on "|" and scope each column independently — which admits pairs
that never changed ("gf|2025" and "water|2026" together also admit
"gf|2026"). The values name the exact pairs, so the read is exact.
iex> groups(%{"fund" => %{"from" => "A", "to" => "ES"}}, :fund)
["A", "ES"]
iex> groups(%{"f" => %{"unchanged" => "A"}, "y" => %{"to" => "25"}}, [:f, :y])
[{"A", "25"}]
The units a version affects, given the consumer's grain.
grain is whatever the node declared — an attribute, a list of them, or a
function — in the same vocabulary Declarative.group_fn/1 accepts, because it
is the same declaration.
Returns a list with one element for a create, a destroy, or an update inside a
unit; two for a move. Never empty, and never :all.
iex> units(%{"fund" => %{"from" => "A", "to" => "ES"}}, :fund)
["A", "ES"]
iex> units(%{"fund" => %{"unchanged" => "A"}}, :fund)
["A"]