Files
rose-ash/next/flow
giles 6c9b96390f
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
fed-sx-types Phase 8: blog-publish-digest e2e + flow:wait
The motivating end-to-end demonstration (fed-sx-triggers-loop.md Phase
4): one trigger arriving in the pipeline drives a multi-step business
flow with a branch, a timer suspension, an injected effect, and a
follow-up activity emit — all in the kernel's own runtime.

- flow.erl: flow:wait/1 — a timer-style suspend that PRESERVES the value
  on resume (vs flow:suspend/1, which returns the logged result), so a
  "wait until morning" step lets the env flow through to later steps.
- next/flow/flows/blog_publish_digest.erl: the flow. Branches on the
  article :category (newsletter -> wait-until-morning -> send + emit;
  urgent -> send + emit now; else -> skip), fetches followers (injected),
  builds a digest email per follower, and emits a DigestSent activity
  OBJECT. Effect-as-data: a flow can't call kernel gen_servers from
  inside the drive (a blocking call there deadlocks the scheduler), so
  it returns the emails + DigestSent object for a driver to dispatch and
  append — which can then trigger downstream flows, closing the loop.

Test: triggers_e2e.sh (10) — urgent completes in one cycle with 3 emails
+ a DigestSent object; newsletter suspends on the morning timer, then
resumes to the same on "advancing the clock"; draft takes the else
branch (no emails); a non-Article note is rejected by the guard; a
duplicate activity fires once. flow:wait covered in next/flow (36/36).

plans/fed-sx-design.md §13.10 documents the trigger fan-out as a
kernel convention. lib/erlang 771/771.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:31:26 +00:00
..

flow-on-erlang — durable workflows in the fed-sx runtime

A native Erlang-on-SX port of the Scheme flow engine (lib/flow), so the fed-sx kernel can fan arriving activities out into durable, branching, multi-step business flows in its own runtime — no cross-guest FFI, no marshalling, no Scheme dependency. The seed of a real engine that can later supersede the Scheme one for substrate use.

Run the suite: bash next/flow/conformance.sh → engine conformance.

Model

A flow is an Erlang fun(Ctx) -> Ctx. Combinators (flow_spec) compose flows; user code stays value-level (the functions you hand to flow_node/branch/… take and return plain values). A flow that ignores its input is a thunk; composition is function composition.

F = flow_spec:sequence([
      flow_spec:flow_node(fun(Draft) -> Draft + 1 end),
      flow_spec:branch(fun(P) -> P >= 3 end,
                       flow_spec:flow_const(ok),
                       flow_spec:flow_const(rejected))]),
flow:run(F, 2)            %% => {flow_done, ok}

Durability — deterministic replay

Same semantics as the Scheme engine: a flow re-runs from the top on every resume; effects/non-determinism go through flow:suspend/1, whose resolved values are logged; an already-resolved suspend replays its logged value, and the first unresolved suspend short-circuits back to the driver. The persisted state is the replay log — plain [{Tag, Value}] data — so nothing live (no continuation, no process) is ever serialized; an instance survives restart by re-driving its named flow against its log.

flow_store:register_flow(publish, F),
{ok, Id, R} = flow_store:start(publish, Draft),  %% R = {flow_suspended, Tag} | {flow_done, V}
%% ... driver performs the effect for Tag, then:
flow_store:resume(Id, EffectResult)              %% re-drives; completes or suspends again

Why railway threading instead of call/cc + a global

The Scheme engine uses an escape-only call/cc plus a mutable global replay log. This Erlang-on-SX runtime can't do either, and has a third sharp edge:

  • No re-enterable continuation — but suspend only needs to escape, which Erlang throw could do …
  • … except a blocking receive / gen_server:call inside a try deadlocks the cooperative scheduler. So suspend must not consult the log via a registry process while inside a try.
  • No process dictionary — so there is no ambient per-process slot to stash the replay log in.

The resolution: thread the replay log through a railway-style context and make suspend short-circuit (like a fail value) rather than throw. No ambient state, no throw, no gen_server in the hot path — purely functional, which sidesteps all three constraints. The driver (flow_store) is the only stateful part, and it calls the pure flow:drive/3 from inside handle_call, never wrapping a blocking receive.

A Ctx is {flow_cont, Value, Log} (running) or {flow_susp, Tag, Log} (short-circuited); every combinator passes a suspended context straight through.

Modules

Module Role
flow.erl pure replay driver: drive/3, run/2, suspend/1, the Ctx constructors/accessors
flow_spec.erl combinator algebra: leaves, sequence/parallel/map_flow, flow_while/flow_until, branch, railway fail/recover/attempt, tap, try_catch/retry
flow_store.erl durable gen_server: named-flow registry + instance table + start/resume/status

Consumed by

The fed-sx kernel's trigger fan-out (pipeline.erl + flow_dispatch) starts named flows from arriving activities; see plans/fed-sx-host-types.md and the triggers phases.

Not yet (later layers)

  • Persisting instance logs to the kernel's durable on-disk log (the data shape is already restart-ready; only the backing is in-memory).
  • parallel with multiple independent suspends resolving concurrently (current parallel is sequential under one shared log).
  • Full parity with the Scheme engine's distributed/remote nodes.