reactive_dag

A domain-agnostic reactive DAG engine for Elixir/Ash apps: a dirty frontier

Documentation: the guides are the front door — Getting started, Configuration, Authoring nodes, LLM nodes, Sources and scanning, and The seams. This README is the reference-style overview.

The substrate decides when and in what order cells recompute; it never decides how or what a value means. Each host brings its domain at the seams:

What the library owns

LayerModuleWhat it provides
Node IRReactiveDag.Celldomain-neutral node; op is an optional free-atom label (load-bearing only for an op-dispatching RecomputeStrategy like SetOp); app fields ride in meta (with an Access impl so cell[:field] reads meta transparently).
Compiled planReactiveDag.Planpure data: cells / parents / depths.
Graph mathReactiveDag.Graphbuild/1 (validate + parent edges + longest-path depths + cycle check); dirty_parents/4 (propagation via the host KeyRule).
Dirty frontierReactiveDag.Frontierclaim-as-delete over the host's dirty table; mark_dirty / next_cell / claim / empty?.
Drain loopReactiveDag.Draindepth-ordered incremental propagation; run/2 parameterized by the two seams, returning {:ok, %Drain.Report{}} — the processing trace (per-step cell/claimed/changed/triggered_by/duration_us + totals). An optional :on_step hook streams the same fields live.
Result readsReactiveDag.Node.Rowsa cell's own rows addressed by CELL KEY (all/1, status_histogram/1, keys_by_status/3) — the read side of what the payload loop writes, and what Insights, Verdict and union are built on. A node's results are ordinary Ash rows; this just keys them the way the DAG does.
Coordination tupleReactiveDag.Tuplethe shared (cell_id, key, status, freshness) spine over the host's tuple table: put / put_changed / rows / present_keys / all_keys / keys_by_status / status_histogram / max_observed_at / reconcile / reconcile_set + a :key_scope selector. This is the WRITE side and the leaf-reconcile path; results are read from each node's resource (see above).
Nested-expr loweringReactiveDag.Loweringwalk/3 — the nested op-expression → flat-cell recursion both DSLs grew, parameterized by host callbacks (id grammar, ref resolution, cell construction).
Compile pipelineReactiveDag.Dslcompile / validate_cells — resolve → structural-validate, with a domain-validation hook.
Op contractReactiveDag.Opthe behaviour a cell's compute module implements (recompute(cell, keys) -> {:ok, changed}) + the write API ops call (put / tombstone / delete, routed to the CoordinationWriter).
Node authoringReactiveDag.Nodethe authoring surface — an Ash resource extension: a resource declares its op + dependencies + computation in a reactive do … end block. The resource is the node and its own payload table. ReactiveDag.Node.graph/2 assembles the Plan from the node resources.
Payload loopReactiveDag.Node.Payloadwrites a combinator's row into the node's own resource (the default; omit upsert:).
Config validationReactiveDag.Configvalidate!/0 at boot, reporting EVERY problem at once (missing :repo, a writer that doesn't implement the behaviour, a table name that isn't a SQL identifier) instead of raising at the first query, possibly a long way into a deploy. The host calls it; the library starts no application of its own.
Content digestsReactiveDag.Basisa versioned digest of a row set, so "is this still what I saw?" is answerable without storing a copy. Sign-off is the motivating use — store the digest with a signature and it lapses automatically when the rows move — but nothing in it knows about signatures. The versioning is the part worth not re-deriving: an unknown scheme degrades to "re-check", never to a crash, so introducing v2 cannot invalidate every stored digest on deploy.
IntrospectionReactiveDag.Insightsthe engine viewed from outside, for a dashboard/mix task/health check: levels/1 + edges/1 (structure), cell_status/2 + summary/1 (status histogram, key count, failing sample — read from each node's own rows), pending/1 (what the next drain would do), and an opt-in rolling window of %Drain.Report{}s (record/1 / recent/1 / last_report/0). All reads, no UI dependency — reactive_dag_dashboard renders it.
Write triggersdirties_onmake ordinary Ash writes trigger the cascade: a create/update/destroy on a leaf resource marks that record's key dirty, inside the write's own transaction (so a rollback leaves nothing, and a commit always leaves the mark). Opt-in; without it the host calls Frontier.mark_dirty/3 at every write site. Contrast Source, which polls state the datastore does not own.
Scanner declarationscan Moda leaf names the ReactiveDag.Source that feeds it, making the scanner↔leaf pairing a fact of the graph: Node.graph/2 verifies the module implements the behaviour AND that its own leaf_cells/1 claims this leaf, and Source.poll_all/2 finds every scanner from the plan instead of a hand-kept list. Single-leaf; a multi-leaf source uses leaf_cells/1 + verify!/2.
Scanner seamReactiveDag.Sourcethe behaviour a scanner implements (id / leaf_cells / poll) — reads external state into a leaf in a poll phase outside the drain; verify!/2 checks every declared leaf resolves to a real cell.

The host owns its physical tables (dirty + tuple, named via config), its op algebra, its recompute executor, and any extension columns on the tuple (the portal's strength modality, cascade's tombstone/fingerprint policy). The library owns the spine and the schedule; the domain differences sit on named seams, not forks.

Authoring a node

A node is an Ash resource with the ReactiveDag.Node extension. The resource IS the node and its own payload table — its reactive block is the computation, its attributes are the rows it materializes. The library closes the payload loop: into returns a row and the lib writes it into this resource — no upsert: needed for the common case.

defmodule MyApp.BudgetRollups do
use Ash.Resource, data_layer: AshPostgres.DataLayer, # its OWN payload table
extensions: [ReactiveDag.Node]
attributes do
attribute :fund, :string, primary_key?: true # the row IS its identity —
attribute :fy, :integer, primary_key?: true # no :key column; the cell
attribute :total, :float # key is "gf|2025", derived
end
actions do
create :upsert do upsert?(true); accept([:fund, :fy, :total]) end
end
reactive do
op :fold
# ASH-FIRST: the library reads :fiscal_lines, groups by the attributes,
# folds each group, upserts the row by its Ash IDENTITY, and Op.puts only
# the changed keys. `recompute_by` names the UNIT a change invalidates —
# it supplies the edge, the grouping and the claim rule. Every slot has an
# escape hatch when the shape outgrows attributes.
recompute_by :fund, to: :fiscal_lines, from: :fund
reduce group_by: [:fund, :fy],
into: [sum: [amount: :total]]
end
end

upsert: is an optional override — supply it only to write somewhere other than the node's own resource (e.g. an existing shadow table). A tableless node (data_layer: Ash.DataLayer.Simple, no attributes) either supplies upsert: or uses the compute Module escape hatch.

Authoring is Ash-first — start from what Ash expresses declaratively and step outward only as far as the shape demands. Each form writes the result set (into the node's resource, or a custom upsert:) and Op.puts only the changed keys:

Beyond Ash entirely — an LLM call, a PDF/Tigris fetch, a bespoke multi-input recompute — the outermost escape hatch is a module: compute MyOp where MyOp implements ReactiveDag.Op. (Mirrors Ash's calculate :x, :type, MyModule — the arbitrary case is an entity too, not a schema key beside the declarative ones.)

Input edges: ref (recompute) vs context (read-as-context)

An input is one of two kinds:

Use context when recompute is expensive/non-deterministic and consults mutable context it shouldn't be re-triggered by — e.g. an LLM step that looks up a human-curated table:

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

So an edit to a context input updates it, but drives no regeneration; the consuming node picks up the current value whenever it next recomputes for its own (recompute-edge) reasons.

reactive do
op :map
compute MyApp.Ops.EventsExtract # arbitrary recompute (LLM, fetch, …)
end
# assemble + run a Node-authored graph (no host-written dispatch):
plan = ReactiveDag.Node.graph([BudgetRollups, FiscalLines,], for_each: &fetch/1)
{:ok, report} =
ReactiveDag.Drain.run(plan,
recompute: ReactiveDag.Node.Recompute, # runs reduce/join/aggregate or compute:
key_rule: ReactiveDag.Node.KeyRule) # reads :identity | :all from the block
# report is a ReactiveDag.Drain.Report — the processing trace: one step per
# recompute (cell, claimed, changed, triggered_by, duration_us) + run totals.
# config
config :reactive_dag,
repo: MyApp.Repo,
dirty_table: "my_dirty",
tuple_table: "my_tuple",
coordination_writer: MyApp.Writer # optional; a spine-only default ships

A host can also assemble cells by hand and bring its own strategy/key_rule — ReactiveDag.Graph.build(cells) + ReactiveDag.Drain.run(plan, recompute:, key_rule:) — which is how both apps ran before adopting the Node surface.

Verdicts are ordinary rows

A node whose answer is one word — a status — is a payload node like any other, writing a :status column.

defmodule MyApp.StoreEncrypted do
use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [ReactiveDag.Node]
# … `key` and `status` attributes, an `:upsert` action …
reactive do
op :reconcile
key_rule :all
reduce over: :stores,
group_by: :store,
into: fn _store, [r | _] -> %{status: if(r.enc, do: "present", else: "failing")} end
end
end

There used to be a second shape for this — verdict? true, with no table, writing the status straight into the coordination tuple. It saved a migration when the answer was one word, and cost a ceiling: the tuple's schema is fixed, so the moment a verdict wanted company (a headroom, a breached_at) the shape had nothing to offer and you abandoned it entirely. A row costs a migration and answers every later question, so verdicts are rows.

Rolling up many verdicts into one graph-wide table is what union from: […] is for.

Human input

Scanners feed leaves out-of-band; a human edit (a managed list, an approval) writes a leaf too — via whatever the host uses for writes (an Ash action, a plain upsert), then marks the affected cells dirty so the drain propagates the consequences.

The library previously shipped a command frontier — a second, seq-ordered frontier for INTENTS, with per-scope serialization, a blocked/answer human-in-the-loop state, and an audit table. It was removed: in both hosts the commands turned out to be straight CRUD drained inline (enqueue immediately followed by run), so nothing was ever actually queued. The serialization it offered was already provided by the database, the audit trail is better served by a change-log on the resource, and its scope-freeze turned a failed edit into a wedged queue. A deferred/approval-gated write — where a change genuinely waits, unapplied, for a human — is the case that would justify bringing it back.

Status: both hosts run on the substrate — the shared engine spans a per-key Elixir recompute (cascade) and a set-based SQL recompute (the portal), all coordination writes routed through the seam, proven by both suites green. Cascade authors several ops via the Nodereduce/join combinators; the standalone compliance app consumes tagged releases. See ADR-001 for the boundary, the seams, and the design law behind them.