Status: accepted & implemented — both hosts fully migrated (path deps); the substrate, coordination tuple, reconcile, DSL lowering, and drain loop are all shared, both suites green. Only publish/pin (phase 3) remains. Date: 2026-08-05 (proposed); implemented same cycle.
This ADR keeps its decision-time framing. Where the build went beyond the original proposal (the tuple ledger DID move into the library as a spine; the DSL WAS lifted via
Lowering/Dsl), the sections below are annotated with what actually shipped rather than rewritten.
Context
Two Elixir/Ash apps independently grew the same reactive-DAG engine:
- cascade (
~/dev/cascade) — the Red Hook data pipeline. Ops are a data-pipeline algebra (map/fold/join/project/union/assemble/sink); recompute is per-key Elixir (each op a behaviour module that may call an LLM, parse a PDF, hit Tigris). Frontiercascade_dirty, ledgercascade_tuple. - u2i-compliance-portal (
~/dev/u2i_compliance/u2i-compliance-portal) — the compliance model_eval. Ops are a set-combination algebra (product/relation/fold/bind/reconcile/analysis/union); recompute is set-based SQL (one join per (cell, dirty-keys) writingmodel_tuple). Frontiermodel_dirty.
They share a skeleton and diverge on the algebra and the recompute model. This ADR extracts the shared skeleton as a standalone library both depend on, with clean seams at the two divergence points.
Both apps are on aligned toolchains — ash ~> 3.5 (3.27 / 3.31), ash_postgres ~> 2.0, spark 2.7.2 (identical) — so the library can be an Ash extension
that owns the substrate tables and (eventually) the Spark DSL, rather than an
ORM-agnostic core that reimplements storage per host.
Decision
Extract reactive_dag: the reactive-DAG substrate + a pluggable
op-algebra layer. A new standalone repo; cascade and the portal both add it as
a dependency.
What the library owns (shared)
| Concern | Module | Notes |
|---|---|---|
| Node IR | ReactiveDag.Cell | op is a free atom — the library never interprets it. meta carries app data. |
| Compiled plan | ReactiveDag.Plan | pure data: cells / parents / depths. |
| Graph build | ReactiveDag.Graph | build/1 (validate + parents + longest-path depths, cycle-checked); dirty_parents/4 (propagation via the host KeyRule). |
| Dirty frontier | ReactiveDag.Frontier | reactive_dag_dirty table; mark_dirty / next_cell / claim / empty?; claim-as-delete. Host supplies its repo via config. |
| Drain loop | ReactiveDag.Drain | depth-ordered incremental propagation; run/2 parameterized by the two seams + an :on_step trace hook. |
| Coordination tuple | ReactiveDag.Tuple ✅ | the shared (cell_id, key, status, freshness) spine over the host's tuple table (put / present_keys / all_keys / keys_by_status / status_histogram / max_observed_at / reconcile + a :key_scope selector). See "what shipped beyond the proposal". |
| Nested-expr lowering | ReactiveDag.Lowering ✅ | walk/3 — the nested op-expr → flat-cell recursion both DSLs grew, via host callbacks. |
| Compile pipeline | ReactiveDag.Dsl ✅ | compile / validate_cells — resolve → structural-validate with a domain hook. |
The two seams (stay app-side)
ReactiveDag.RecomputeStrategy—recompute(cell, keys) -> {:ok, changed}. How a cell recomputes. cascade dispatches tocell.meta.compute(Elixir per key); the portal dispatches bycell.opto a SQL template. The substrate only decides when/order, never how — this is the SQL-vs-Elixir split.ReactiveDag.KeyRule—rule(parent, child, changed)returns:allor a{:keys, mapped}fan-out. How a change propagates. cascade's uniform:identity | :allfield is the trivial case; the portal's per-op, per-which-input rules (product/relation escalate to:allwhen their fn leg changed, pass keys when their members leg changed) are expressible because the callback sees which child changed.KeyRule.identity/3is the default.
The tuple ledger was originally scoped OUT of the library. What actually
shipped is more precise (see "what shipped beyond the proposal"): the spine —
(cell_id, key, status, freshness) + the read/reconcile operators over it — IS
shared (ReactiveDag.Tuple), because both hosts had the identical spine spelled
two ways. What stays host-side is the payload (each host's typed resources,
joined by key) and the extension columns (the portal's strength modality,
cascade's tombstone/fingerprint policy). The write executor is still the
RecomputeStrategy seam.
Why not more, why not less
- Not less (bare graph, host does storage): both hosts are AshPostgres, so
making them each reimplement the frontier is needless — the library owning it
removes real duplication (cascade's
Frontier+ the portal'smodel_dirtyaccess are ~the same raw SQL). - Not more (unify the algebra + recompute into the library): the algebra and recompute model are precisely where the apps diverge on purpose. Forcing one algebra would either cripple cascade's LLM-per-key ops or cripple the portal's set-based scale. The seams keep both first-class.
Migration cost
cascade (low — it was built as this generalization):
- Replace
Cascade.Engine.{Cell,Plan,Graph,Frontier,Drain}withReactiveDag.*. Cascade.Engine.Op→ implementReactiveDag.RecomputeStrategy(a thin dispatcher tocell.meta.compute); the existing per-op modules are unchanged.- Cascade's
key_rulefield → a smallKeyRulemodule (:identity→{:keys},:all→:all). Move the field intocell.meta. - Rename
cascade_dirty→reactive_dag_dirty(or alias). Keepcascade_tuple(host-owned ledger). - Trace/PullDetail/scans stay in cascade via the
:on_stephook.
portal (moderate):
ModelEval.Graph.build/1currently takes a module; split into "lower DSL → cells" (stays portal-side, phase 1) +ReactiveDag.Graph.build(cells).ModelEval.RecomputeSQL templates → wrap behind aRecomputeStrategythat dispatches bycell.op.- The per-op
dirty_parentsrules → aKeyRulemodule. model_dirty→reactive_dag_dirty;model_tuplestays portal-owned.
Phasing — as executed
- Phase 0: skeleton repo + interfaces. ✅
- Phase 1: cascade migrated (Frontier/Graph/Drain delegated), full suite green. ✅
- Phase 2: portal migrated onto the shared Drain (proving the boundary
spans set-based SQL recompute too); DSL refs unified by-name in both apps;
Lowering.walk+Dslextracted and adopted by both. ✅ - Beyond the original phase 2 (the shared substrate turned out deeper than
just frontier + graph): the coordination-tuple spine (
ReactiveDag.Tuple)- the leaf-
reconcileskeleton + spine reads with a:key_scopeselector; and the Drain loop enriched (on_stepcarriestriggered_by+duration_us) so cascade dropped its bespoke trace loop and the portal lowers throughwalk. ✅
- the leaf-
- Phase 3 (remaining): publish (hex or a git tag) and pin both apps off the
path:deps.
What shipped beyond the proposal
The proposal scoped the library at frontier + graph + two seams, with the tuple ledger and DSL left host-side. Migrating both apps revealed the shared surface was larger, and in exactly the places the proposal was unsure about:
- The coordination tuple is shared (spine), not host-only. Both hosts had a
thin
(cell_id, key, status, freshness)row keyed identically — cascade'scascade_tuple, the portal'smodel_tuple— plus a typed payload resource joined bykey. The spine + its read/reconcile operators becameReactiveDag.Tuple(host owns the physical table viaconfig :reactive_dag, tuple_table:, extension columns and all). This is the resolution of the first open question: not frontier-only; the spine is a genuine shared layer, the payload and extension columns are not. - The DSL lowering is shared (primitives), not per-app. Resolving the second
open question: refs are by-name in both apps, and the nested-expr → flat-
cell recursion is one
Lowering.walkprimitive both DSLs call with their own callbacks. Each app keeps its vocabulary (source/dataset/targetvsguarantee/control/register) as a thin skin — primitives + per-app skin, as guessed. reconcile/3unified the leaf-write algorithm. The portal's ~12 leaf drivers and cascade'srefresh_leafwere the same skeleton (upsert a desired set, retire the vanished); the one real divergence — delete vs tombstone — is now a one-line:retireseam.- A THIRD seam appeared: the coordination write (
CoordinationWriter). Ops used to write tuples via the host frontier directly; now viaReactiveDag.Op.put/tombstone/delete, routed to a host-configuredCoordinationWriter. Why a seam and not a pureTuplecall: the write touches each host's extension columns in the same atomic upsert (cascadesource_ref/tombstone, portalstrength), so it's host policy. A spine-only default ships. Every op/driver in both apps now writes through this — the library owns the single INSERT/DELETE path (except a couple of genuinely multi-grain writes that stay host-side, e.g. the portal's concern-table prune). - Node authoring: the resource IS the node (
ReactiveDag.Node). Beyond the runtime substrate, the library gained an Ash resource extension — a resource declares its op + dependencies + computation in areactiveblock, andReactiveDag.Node.graph/2assembles the wholePlanfrom the node resources (the central pipeline module dissolves onto the resources). Computation is declared with three combinators —reduce(fold; itsintomay return a list of rows, the group → many-rows "expand" shape),join(index one input into left/right sides), andaggregate(a pure-datastore relationship aggregate) — for the common map/reduce shapes, withcompute Module(aReactiveDag.Opentity) as the escape hatch for LLM/fetch/bespoke recomputes. This is the Ash calculation model (DSL for common, module for arbitrary) applied to DAG nodes.Node.Recompute/Node.KeyRulerun a Node graph with no host-written dispatch. Cascade authors several ops this way; full adoption of either app's pipeline remains a deliberate migration (escape-hatch ops gain nothing from it).
The design law behind the seams
Why RecomputeStrategy is a seam and not shared code, stated as the principle we
converged on: the reactive substrate is shared because scheduling is
purpose-neutral; recompute is not shared because it encodes the app's purpose.
The portal is a proof engine — every node's codomain collapses to a status
lattice (present/failing × strength), which is exactly what set-based SQL is
total over. Cascade is a data pipeline — nodes produce open-typed values (a
dollar variance, an LLM-extracted record), which needs the BEAM. Purpose →
codomain (status-lattice vs open value) → executor (SQL vs BEAM). The one
domain difference that is not an executor choice is modality (strength —
how well-established a fact is), which the portal computes and cascade has no use
for; it rides as a tuple extension column, never forced onto cascade.