The post-append fan-out that fires durable flows from arriving
activities (fed-sx-triggers-loop.md Phases 2+3), native into next/flow
— no cross-guest FFI.
- pipeline.erl: apply_triggers/3 runs AFTER the kernel append (rejected
activities never reach it). It looks the activity's type up in the
trigger registry, drops specs whose guard/actor-scope fails or whose
{activity_cid, trigger_cid} pair already fired (federation can deliver
the same activity twice — dedup is keyed on that pair, read from the
actor's :triggers_fired), and dispatches the rest. Returns the audit
triples for the kernel to fold into :triggers_fired + its projection.
Must not be called inside a `try` (it does gen_server:calls, which
deadlock the scheduler inside a try); running post-append in its own
step satisfies that.
- flow_dispatch.erl: bridges a matched trigger to flow_store:start, with
the activity bound into the flow's input env. guard_passes/3 gates on
actor-scope + guard. Failures (unknown flow, crashing first step) come
back as {error, _}, never raised — one flow can't take down the rest.
- flow_store.erl: drive wrapped in try (the drive is pure, so the try is
safe) so a flow whose step raises yields {error, {flow_crashed, _}}
instead of crashing the store.
Tests: flow_dispatch.sh (12), pipeline_triggers.sh (10). lib/erlang
771/771, next/flow 34/34.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
throwcould do … - … except a blocking
receive/gen_server:callinside atrydeadlocks the cooperative scheduler. Sosuspendmust not consult the log via a registry process while inside atry. - 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).
parallelwith multiple independent suspends resolving concurrently (currentparallelis sequential under one shared log).- Full parity with the Scheme engine's distributed/remote nodes.