A node is an Ash resource with the ReactiveDag.Node extension. The resource
is the node and its own payload table: the reactive do … end block declares
the computation; the resource's attributes are the rows it materializes. This
guide covers every shape that block can take.
The four node shapes
| shape | data_layer | attributes | reactive block | result lives in |
|---|---|---|---|---|
| payload | AshPostgres/Ets | the payload columns + an :upsert action | a combinator, no upsert: | the resource itself |
verdict (verdict? true) | Ash.DataLayer.Simple | none | a combinator with status: | the coordination tuple |
| write-elsewhere | Simple | none | a combinator + a custom upsert: | wherever upsert: writes |
| escape hatch | Simple | none | compute MyOp | up to the op |
The line between payload and verdict is exactly whether the result fits the tuple's fixed schema. A verdict node that declares payload attributes raises at compile time — the attributes would silently never be written.
Declaring the computation
Authoring is Ash-first: start from what Ash can express declaratively and
work outward — each step down the ladder trades declarativeness for power, and
you take only the steps your shape needs. Every form reads its input, computes
the result set, writes it (into the node's own resource by default), and
Op.puts only the changed keys, so downstream work is proportional to
real change.
| rung | you write | when |
|---|---|---|
aggregate | attribute atoms only | the fold is a datastore aggregate over a relationship |
recompute_by + reduce | the unit a change invalidates, then the fold | you know what a change should re-do (the common case) |
declarative reduce/join | attributes + fold keywords | grouping/joining by attributes; the library reads Ash for you |
| per-slot escapes | a fn for the one slot that outgrew attributes | query:, computed groups/keys/rows, expand:, status: |
run :action | a generic Ash action on this resource | arbitrary recompute that should stay a first-class action |
compute Module | a ReactiveDag.Op | recompute that outgrows Ash entirely |
aggregate — the datastore does it
aggregate over: :dmr_reports, # a has_many on THIS resource
count: :day_count,
avg: [flow: :avg_flow],
max: [flow: :peak_flow]Postgres does the GROUP BY; no rows cross into the BEAM. Only expressible
as a relationship aggregate (the group must be a resource with a relationship
to the input) — for anything else, step down to reduce.
Same vocabulary as the in-BEAM fold. aggregate and reduce into: take
the same kinds (count/sum/avg/min/max/first), the same
[src: dest] spelling, the same SQL nil semantics, and the same key rules
(a composite primary key makes either node identity-keyed). One list backs
both, so they cannot drift — moving a fold between the datastore and the BEAM
does not change the answer, only who computes it:
| who aggregates | rows into BEAM | recompute unit | |
|---|---|---|---|
aggregate | Postgres, in one query | none | whole cell, always |
reduce into: | the BEAM | the scoped slice | whatever recompute_by says |
The trade is real in both directions: aggregate reads nothing into the BEAM
but reprices every group; reduce + a tight recompute_by reads only the
claimed slice, which often wins for a big table with fine-grained changes.
reduce — an in-BEAM fold, declared
reduce over: :fiscal_lines,
group_by: [:fund, :fy], # group by attributes
into: [sum: [amount: :total], count: :n] # fold each groupNo read: — the library reads the over node's resource (its primary read
action), automatically scoped to the claimed dirty keys by filtering the
over's payload key. No key: — the group's values join with "|"
("gf|2025"); key_prefix: "roll" namespaces ("roll|gf|2025"). The row is
the group's attributes plus the fold results (count/sum/avg/min/max/
first, nil sources excluded, SQL-style), written into this resource by the
payload loop. read: :recent names a :read action on the over resource
instead of its primary — same auto-scoping.
Keys are Ash keys. A single-attribute primary key is the payload key,
derived — payload_key exists only for non-PK key columns. Better: declare a
composite primary key (fund + fy) and drop the key column entirely —
the row IS its identity, the upsert conflicts on the primary key, and the cell
key is the identity's serialization in primary-key order. The verifier checks
every identity field is produced by the row (group columns ∪ fold dests). And
the DAG edge reads as a relational join: a group_by entry may be the
pair parent_column: :child_field (group_by: [fund: :fund_code, fy: :fy] —
"this node's fund = the child's fund_code").
The recompute unit: recompute_by
The declaration the engine actually cares about is what unit a change
invalidates. Everything else — group_by, into, key derivation — is
mapping data into shape once you already know what to recompute.
reactive do
recompute_by :category, to: :expenses, from: :expense_cat
reduce into: [sum: [amount: :total], count: :n]
endRead it as a sentence: recompute by category, from the input's
expense_cat. A change to a row's expense_cat invalidates my category
unit, so redo it whole. That one fact supplies the input edge, the
grouping, the claim resolution and the read scope — which is why it
replaces key_rule entirely. The same unit used to be stated twice, once as
the grouping and once as the claim rule, and the two had to agree.
Four answers to the one question:
| declaration | the unit a change invalidates |
|---|---|
| (omitted) | key-for-key — a changed input key maps to the same output key |
recompute_by :cat, from: :field | per unit, resolved by reading the changed rows; a key the lookup can't find (a deleted row) degrades to whole-cell |
recompute_by [fund: :fund_code, fy: :fy] | a composite unit — the grain IS the grouping, so group_by: is not restated |
recompute_by :month, from_key: true | per unit, resolved purely from the changed key's |-segments — no query, deletion-safe, at the price of the key-grammar contract |
recompute_by :cell | the whole cell — any change re-does everything |
Composite grain
A unit can be several columns. State the pairs once and the grouping follows:
recompute_by [fund: :fund_code, fy: :fy], to: :lines
reduce into: [sum: [amount: :total], count: :n]Reads as rollups.fund = lines.fund_code AND rollups.fy = lines.fy. The cell
key serializes the columns in order ("gf|2025"), and with a composite primary
key the row is identity-keyed — no key column at all.
The read is scoped per column: a claim of "gf|2025" becomes
fund_code IN ("gf") AND fy IN ("2025"). For several claims that admits a
cross-product superset ("gf|2025" + "water|2026" also matches "gf|2026")
— still sound, since a superset read stays closed over unit boundaries, and far
tighter than reading the whole table. Columns that aren't plain strings don't
invert; the fold sorts them out.
It is the recompute unit, not the output's grain. They coincide for a plain
rollup and diverge the moment one unit emits many rows: percentile
distributions recompute_by :day (touch one reading and the whole day is
re-derived) while the rows themselves are keyed day+percentile via expand:.
recompute_by :day, to: :readings, from: :date
reduce expand: fn day, rows -> percentiles(day, rows) endThe unit is consumed at compile time — it lowers to over: + group_by: +
the claim rule, and nothing traverses it at recompute. One declaration per
node, so a combinator reads exactly one input: one unit, one claim translation.
A node reads its input, materializes rows, and downstream consumers query
those rows rather than re-deriving them back up the chain.
Retirement: units that stop existing
A fold writes the units it produced — and reconciles the ones it didn't. A unit whose input rows have all gone produces nothing, so without this its last computed value would linger forever, and a stale derived row is indistinguishable from a live one.
Retirement covers both sides of the node: the payload row is destroyed (so
the derived table stops showing the unit) and the coordination tuple is
deleted (so the retirement propagates downstream as a changed key). A node that
can retire therefore needs a destroy action — defaults [:destroy], or name
one with payload_destroy.
What a pass may retire is bounded by its claim: a whole-cell pass reconciles everything the node holds, a scoped pass only the units it claimed. Reconciling wider would retire live units that simply weren't visited.
A node with a custom upsert: owns its own writes, so the library does not
reconcile on its behalf.
Limit — a row moving between units. The claim names where the row landed; the unit it left is invisible, because nothing records which unit an input key previously fed. The origin is repriced by the next whole-cell pass rather than the scoped claim. Fixing it exactly needs input-key → unit provenance.
A combinator read is always an Ash read — to shape it, stay in the query:
reduce over: :fiscal_lines,
query: fn q, _dirty -> Ash.Query.filter(q, posted == true) end,
group_by: fn line -> {line.fund, line.fy} end, # computed group
key: fn {fund, fy} -> "#{fund}|#{fy}" end,
into: fn {fund, _fy}, lines -> %{fund: fund, total: sum(lines)} endquery: receives the base query and the claimed dirty keys (nil =
whole-cell) and returns a query — filter, sort, load, without leaving Ash's
pipeline (policies still apply); the library executes it and applies the
dirty-key scope afterwards, so scoping stays the substrate's job. The other
slots resolve independently — a declarative group_by with an into: fn is
fine (the fn receives the group tuple exactly as the fn idiom always has). A
read that isn't Ash at all belongs on the run/compute rungs.
Two shapes have their own slots instead of into::
- verdict (
verdict? true) — declarestatus:((group, items -> status | {status, strength})); the verdict IS the result, keys derive as usual. - expand — declare
expand:((group, items -> [row]), each row carrying its own:key, since one group fans out to many keys).
The classic: date-bucketed rollups
A group_by: entry may name a calculation as well as an attribute — so a
derived grouping value (the classic being a calendar bucket) is declared where
Ash puts derived values: on the resource that owns the data. The library loads
it in the read; the bucket label becomes the group column and the derived
cell key.
# on the data's resource — usable by ANY Ash consumer, not just the DAG
calculations do
calculate :month, :string, {ReactiveDag.Calendar, bucket: :month, of: :date}
end
# the rollup node: daily readings → monthly totals, keys like "2026-08"
reduce over: :readings,
group_by: [:month],
into: [sum: [value: :total], count: :n]ReactiveDag.Calendar ships :day/:week/:month/:quarter/:year
buckets, computes in the BEAM (works on every data layer), and its labels sort
chronologically. A Postgres host wanting pushdown declares an
expr(fragment("to_char(?, 'YYYY-MM')", date)) calculation instead — the
rollup neither knows nor cares.
And the mid-granularity claims come from the same declaration — the unit:
recompute_by :category, to: :expenses, from: :category
reduce into: [sum: [amount: :total], count: :n]A changed child key is resolved by reading the changed rows and evaluating
the unit's from: field (one scoped query per propagation; a key the lookup
can't find — a deleted row — degrades to whole-cell, since vanish must reprice
everything it might have left). When the unit is one plain string attribute,
the library also scopes the read to the claimed units (category in claims).
from_key: true trades that lookup for PURE resolution when keys carry the
unit's input fields as leading |-segments ("2026-08-11|r4" — a plain
attribute's value, or a Calendar calculation's raw date, relabeled through the
same calculation group_by names): no query, and deletion-safe (a vanished key
still names the unit it left). One declaration, two resolutions — there is no
separate calendar rule, because the calendar already lives in exactly one
place: the group_by calculation, which grouping, scoping, and claiming all
read. Chained rollups (readings → daily → monthly) make every step a pure
relabel of the child's key.
The general soundness rule behind all of it: a scoped read must be closed
over unit boundaries — the omitted (identity) case is entry-closure,
recompute_by :cat (either resolution) is unit-closure, :cell is the
universe. The read auto-scope inverts claims through the same group plan: a
plain string attribute filters by equality, a Calendar bucket by its date
range. key_rule at block level remains for nodes with no combinator
(run/compute/leaves); declaring it alongside recompute_by is a compile
error, since they are the same fact.
test/date_rollup_demo_test.exs and test/group_rule_test.exs are the worked
demos: touch one reading (or one expense), watch exactly one month (or
category) recompute and propagate.
join — a left join (one input, two sides), declared
join over: :entries,
left: [key: :acct, where: [kind: "budget"]], # side = discriminator + key
right: [key: :acct, where: [kind: "actual"]],
into: [left: [amount: :budget], right: [amount: :actual]]One row per left key, right side optional; an absent side yields nil
columns, so the declared-vs-observed gap is information, not an error. A plain
attribute is the two-column case (left: :declared_id — a nil value means
"not on this side"); [key:, where:] splits ONE input into sides by a
discriminator field. outer: true also emits right-only keys (an undeclared
member is a finding). The fn escapes: left: fn item -> ... end for computed
side keys, into: fn jk, l, r -> ... end for computed columns
(variance = budget − actual); a verdict join declares status:
((jk, l, r -> status | {status, strength})) instead of into:. query:
shapes the read exactly as on reduce.
run — a generic Ash action as the recompute
actions do
action :extract, {:array, :string} do
argument :keys, {:array, :string}, allow_nil?: true # nil = whole-cell
argument :cell_id, :string
run fn input, _ctx ->
changed = MyApp.Extract.run(input.arguments[:keys])
{:ok, changed} # the CHANGED keys
end
end
end
reactive do
run :extract
ref :transcripts
endThe Ash-native escape hatch — one step less escape than a module, because the
computation stays a first-class action: arguments, policies, testable with
Ash.run_action. The library passes only the arguments the action declares
(keys, cell_id — declare neither for a whole-cell recompute), the action
does its own domain writes, and the library Op.puts each returned key. The
action must exist and be generic — verified at compile time.
An LLM node is this rung with an ash_ai
prompt-backed action behind it — no library code required. See
LLM nodes for the shape, the ref vs context billing
distinction, and how to test one without a model.
compute — the outermost escape hatch
compute MyApp.Ops.EventsExtract # implements ReactiveDag.OpFor recompute that outgrows Ash entirely: an LLM call, an external fetch, a
bespoke multi-input recompute. The op receives (cell, dirty_keys), reads its
inputs however it likes, writes via ReactiveDag.Op.put/3, and returns the
keys that actually changed.
Input edges
ref :transcripts # recompute edge: a change dirties this node
context :people # read-as-context: consulted, never triggers
depends_on [:a, :b] # flat sugar — one ref per id
reduce over: :x, ... # a combinator's `over:` implies a ref
recompute_by :x, to: :xs, ... # ...as does `recompute_by to:`
ref :machines, gate: :ownership # gated: consume through the attested viewref vs context is the load-bearing distinction — a change to a ref
target propagates; a context target is read as settled context and never
triggers. A context edge is still a real input — validated, depth-ordered so
the target settles first, read at recompute. Use it when recompute is
expensive or non-deterministic and consults mutable context it shouldn't be
re-run by:
reactive do
op :map
compute MyApp.EnhanceMinutes # an LLM pass
ref :transcripts # a transcript change RE-RUNS the LLM
context :people # a people edit does NOT — the LLM just reads
# current people the next time it runs
endOne boundary: a context edge still participates in depth ordering (that is
what guarantees the target settles first), so it cannot form a cycle —
Graph.build raises. It reads settled upstream context; it is not a feedback
mechanism.
gate: is covered in the Attestations guide — it interposes
an attested view on the edge, admitting only signed rows.
Nested expressions: compose
A leg can be an inline anonymous cell rather than a named node — the op-algebra expression-tree form:
reactive do
id :meeting_shell
op :union
compute ShellOp
ref :agenda_docs
compose :fold do
as :projected_meetings # explicit id; else positional "<parent>/<i>"
compute ProjectOp
ref :resolutions
ref :meeting_events
end
endEach compose lowers to its own intermediate cell (addressable, depth-ordered,
recomputed like any other); compose legs nest.
Cell ids
A node's id defaults to its module's short name, snake-cased
(MyApp.FlowMonth → :flow_month); override with id :name. This id is the
vocabulary of every edge — ref, depends_on, over:, and the ids passed
to graph/2. When an edge fails to resolve at assembly, it is almost always an
id mismatch.
Key rules
A combinator node declares its recompute unit with
recompute_by, which subsumes this. For a
node with no combinator (run/compute/leaves), the block-level
key_rule still declares how a child's changed keys map onto this node's
recompute:
:identity(default) — child keykchanged → recompute my keyk. For same-grain pipelines.:all— any child change → whole-cell recompute. For folds whose output grain differs from the input's.
A custom mapping (prefix-remap, expansions) means bringing your own
ReactiveDag.KeyRule — see The seams.
Generators: one sub-tree per member
A node with for_each: is a template: it builds no cell of its own.
Instead, graph/2 expands one instance sub-tree per member of a population:
reactive do
id :rule_concern
for_each :rules # a population atom
op :probe
compute ProbeOp
ref :edr_agents
end
plan = ReactiveDag.Node.graph(resources, for_each: fn :rules -> fetch_rules() end)
# → cells "rule_concern.r1", "rule_concern.r2", … each with the member's meta stamped onA member is any map with an :id (plus optional :meta, merged onto every
instance cell — the per-member stamp, e.g. a probe filter). Without a fetcher,
a generator node is skipped.
Companion cells
companion op: … builds a two-cell node: the op-tree roots at
<id>/<suffix> and a companion cell at <id> is a derived view over it — the
node PLUS a projection of it, both addressable. This is the shape a
three-valued verdict historically needed (all evaluated members in one cell,
only the problem rows in the other).
Note that first-class coverage — keeping covered rows in the guarantee
cell itself, so green vs never-evaluated falls out of one histogram — makes the
companion unnecessary for that use. Reach for companion only when you
genuinely want two addressable views of one computation.
Assembly
plan = ReactiveDag.Node.graph([NodeA, NodeB, ...], for_each: &fetch/1)Assembly is where cross-resource resolution happens: refs are checked against real cells, ids must be unique, cycles are rejected, attestation requirements are resolved and their interposed cells manufactured. A broken graph fails here, with the offending id in the message.