The substrate decides when and in what order cells recompute; it never decides how, or what a value means. Everything domain-shaped enters through three named seams — the same three that let two very different hosts (a per-key Elixir pipeline calling LLMs, and a set-based SQL compliance model) share one engine without forking it.
This guide is for hosts going beyond the ReactiveDag.Node authoring surface:
custom recompute strategies, custom key propagation, and hand-assembled graphs.
Seam 1: ReactiveDag.RecomputeStrategy — how a cell recomputes
@callback recompute(cell, dirty_keys :: [key] | :all) :: {:ok, changed :: [key]}The drain claims a cell's dirty keys and hands them to the strategy; the strategy does the work — per-key Elixir, one set-based SQL statement, an LLM call — and returns the keys whose output actually changed. Only those propagate: the contract that keeps a cascade O(real changes) instead of O(graph size).
Two strategies ship:
ReactiveDag.Node.Recompute— dispatches on thereactiveblock's combinator (reduce/join/aggregate) orcomputemodule. WhatNode-authored graphs use.ReactiveDag.SetOp— dispatches oncell.opto a host-supplied SQL template. What a set-based host uses; this is the one placeCell.opis load-bearing rather than documentation.
Seam 2: ReactiveDag.KeyRule — how a change propagates
@callback rule(parent_cell, child_id, changed_keys) :: {:keys, [key]} | :allWhen child c reports changed keys, the rule decides what that means for each
parent: the same keys (:identity — same-grain pipelines), a whole-cell
recompute (:all — grain-changing folds), or any host remapping (prefix
grammars, one-to-many expansions). ReactiveDag.Node.KeyRule reads
:identity | :all off the authored block; bring your own for a real key
grammar.
Seam 3: ReactiveDag.Source — how the world gets in
Covered in depth in Sources and scanning: id/0,
leaf_cells/1, poll/1 → changed keys, with polling deliberately outside the
drain. The seam exists because fetching is effectful and fallible while the
drain must stay pure and re-runnable.
Where results live
There is no shadow table. A node's results are rows in that node's own resource, with that resource's own columns, types, policies and migration.
This was not always so. The library used to write a coordination tuple — a row
per (cell_id, key) in a side table — carrying a status and freshness
alongside every result, with a CoordinationWriter seam so a host could stamp
its own extension columns into the same upsert. It existed because a node could
be tableless: a verdict node had nowhere else to put its answer.
Once every node had a resource, that table had nothing left to record that the
resource did not already say, and the seam had nothing left to write. A host
that wants a source_ref, a last_seen_at or a tombstone flag puts it on the
node's resource, where the rest of the row already is.
What remains of that machinery is ReactiveDag.Node.Rows.reconcile/3 — the
set math a leaf driver needs (current − want → retired), reading the leaf's
own rows.
Hand-assembled graphs
The Node extension is one authoring surface, not the substrate. A host can
build ReactiveDag.Cell structs directly — or lower its own DSL — and run the
same engine:
cells = [
%ReactiveDag.Cell{id: "machines", op: :leaf, leaf?: true},
%ReactiveDag.Cell{id: "verdict", op: :reconcile, inputs: ["machines"],
meta: %{compute: MyApp.ReconcileOp}}
]
plan = ReactiveDag.Graph.build(cells)
ReactiveDag.Drain.run(plan, recompute: MyStrategy, key_rule: MyKeyRule)A strategy with something worth reporting about the work returns
{:ok, changed, meta} instead of {:ok, changed} — an arbitrary map that rides
on the %Report{} step (Report.total/2 rolls one key up across steps). The
library never interprets it: token/cost counts, cache hits, retries and rows
scanned are all just keys.
For a host with its own nested expression DSL, ReactiveDag.Lowering.walk/3
is the shared recursion (parameterized by id grammar, ref resolution, and cell
construction), and ReactiveDag.Dsl.compile/2 adds structural validation plus
a domain-validation hook. Cell.meta is an open map the substrate passes
through untouched — with an Access impl so cell[:field] reads meta
transparently; carry whatever your strategy needs.
The design law
One sentence governs what goes where: if it mentions the domain, it is the host's; if it decides scheduling or identity, it is the library's. The full argument — including what was deliberately removed (a command frontier whose queue never actually queued anything) — is in the repository's ADR-001.