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

shapedata_layerattributesreactive blockresult lives in
payloadAshPostgres/Etsthe payload columns + an :upsert actiona combinator, no upsert:the resource itself
verdict (verdict? true)Ash.DataLayer.Simplenonea combinator with status:the coordination tuple
write-elsewhereSimplenonea combinator + a custom upsert:wherever upsert: writes
escape hatchSimplenonecompute MyOpup 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.

rungyou writewhen
aggregateattribute atoms onlythe fold is a datastore aggregate over a relationship
recompute_by + reducethe unit a change invalidates, then the foldyou know what a change should re-do (the common case)
declarative reduce/joinattributes + fold keywordsgrouping/joining by attributes; the library reads Ash for you
per-slot escapesa fn for the one slot that outgrew attributesquery:, computed groups/keys/rows, expand:, status:
run :actiona generic Ash action on this resourcearbitrary recompute that should stay a first-class action
compute Modulea ReactiveDag.Oprecompute 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 aggregatesrows into BEAMrecompute unit
aggregatePostgres, in one querynonewhole cell, always
reduce into:the BEAMthe scoped slicewhatever 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 group

No 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]
end

Read 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:

declarationthe unit a change invalidates
(omitted)key-for-key — a changed input key maps to the same output key
recompute_by :cat, from: :fieldper 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: trueper unit, resolved purely from the changed key's |-segments — no query, deletion-safe, at the price of the key-grammar contract
recompute_by :cellthe 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) end

The 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)} end

query: 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) — declare status: ((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
end

The 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.Op

For 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 view

ref 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
end

One 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
end

Each 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 edgeref, 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 key k changed → recompute my key k. 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 on

A 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.