All notable changes to FSL for Elixir (hex package finite_state_language,
OTP app :fsl, modules FSL.*).
The format follows Keep a Changelog, and
this package follows semantic versioning. Note what that
means for the field names: lasterr, errorreason, currentstate and
laststate are struct fields a deployed .exs machine reads directly, and a
struct field takes no deprecated alias — so renaming one is a major version
with a migration, never a tidying.
[0.2.0] — 2026-09-12
The first release of this package, numbered to match the TypeScript
implementation rather than to count this package's own releases. The two honour
the same contract, reconciled clause by clause in
spec/fsl-js-ts.md
§12 — the SBB return shape, the declared vocabulary, the block-level bound,
resume:, the inter-machine event names.
Added — the first release
Extracted from Elixip on 2026-09-12,
where the language and its engine had lived inside a SIP stack since 2024, and
relicensed from BUSL-1.1 to Apache-2.0 so that the Elixir and TypeScript
implementations ship under one licence (see NOTICE).
The language:
FSL.Machine—state,goto(a named state,next,loop,back),stay,on_eventswith its selective-receive semantics and its absolute deadline,config,scenario_success/scenario_failure/scenario_aborted,spawn_fsm/notify/notify_parent/on_shutdown;FSL.Block— service building blocks: a subroutine of the language, entered withsbb_fsmand returning{namespace, outcome, data}, with the outcome vocabulary checked at compile time, a completion bound per block, and acleanup/1that runs on every way out — including an enclosing block's deadline abandoning it, the exit no hand-written release could cover;FSL.Context— the six fields a machine keeps about itself, the five generic context macros, and@after_compileso a binding that forgot them fails to compile;FSL.Runner— the engine: one FSM per process, a flat call stack across any number of transitions, a fixed teardown order, and three outcomes so a controller-driven stop is not counted as a failure.
A runnable sample:
samples/fishing.exs— a fishing trip that plays itself in two seconds and prints the afternoon as a Mermaid diagram. It needs the deadline that does not restart,stay,goto back, a sub-FSM and a host, for reasons a reader already believes rather than reasons they have to be taught.
The embedding:
FSL.Host— the twelve callbacks a protocol binding provides, named atusetime and recorded on the machine's own module, so two bindings coexist in one VM;FSL.Host.Default— what a machine with no protocol gets;FSL.Test.Host— an observable host: every hook appends to a trace or messages the process that started the machine. What this package's own suite runs against, and the worked example.
Instrumentation:
FSL.Monitor— a live registry, one row per run, with the columns an embedding declares at start and writes withnote/2;subscribe/1pushes{:fsl_monitor, {:updated | :cleared, …}};FSL.Journal, and two renderers behind theFSL.Diagrambehaviour —FSL.Diagram.PlantUMLandFSL.Diagram.Mermaid. A per-run journal in the process dictionary, rendered as a sequence diagram whose lane rule is by exclusion, so a protocol a renderer has never heard of is still drawn from the peer. Mermaid is what the TypeScript sibling emits and what GitHub renders with no toolchain; a binding picks one withFSL.Host.diagram_renderer/0;FSL.Loader,FSL.Child,FSL.Valet, andFSL.HTTP(HTTP-as-events, the one module needingReq, declaredoptional: true).
Changed relative to Elixip's DSL
Nothing a machine writes. The extraction was a refactor of the implementation and
not of the language: Elixip's .exs scenarios, its kelixip scripts and every
use SIP.Scenario line were untouched, and SIP.Scenario &c. remain the names
that binding calls the engine by.
What did change is who answers for what, and it is worth listing because a second binding meets exactly these:
- the context belongs to FSL and a binding extends it, rather than the FSM's bookkeeping living in a protocol's struct;
- the context variable is a parameter (
ctx_var:).sip_ctxwas the only name the language could bind; :sipas the fallback event type is the binding's, not the language's. An unrecognised leading atom read as coming from the peer is a sentence about a protocol;- the injected-clause families are asymmetric, on purpose. A cooperative
shutdown is the FSM control protocol and only an explicit
:scenario_ctlclause opts out of it; a binding's failure domain is a policy default and its suppression test is generous; - the three per-event hooks are one callback, so the order they run in is three statements of one function rather than the expansion of a macro;
__scenario_type__/0is an opaque slot with defaultnil.uas :registeris a SIP macro on SIP's facade;- the monitor's row is flat and its host columns are declared, which is what kept every consumer of those rows unchanged;
:log_sequenceis read under:fsl, not under a binding's app. A binding that keeps its configuration in one namespace names it once withconfig :fsl, :log_sequence_app, :my_app;- a host inherits
FSL.Host.Defaultfor the callbacks it leaves out. It used to get the literal each call site passed, which forFSL.Host.build_context/1meant an empty context — a machine's wholeconfigblock dropped on the floor, silently, the moment its host implemented any other callback. Found by writing the sample; - the diagram's lane labels are read from
:label/:peer, falling back to:username/:domainand then to the machine's own name. Those last two are SIP config keys that a generic renderer had no business knowing as its only source.
Fixed
FSL.Monitor.subscribe/1returned:ok, so a caller had to readcalls/0separately — two calls with a window between them, survivable in one order only. It returns the snapshot, taken in the call that registers the subscriber;- a subscriber was never monitored, so one that died stayed in the set for
the life of the node and every later change was
send/2into the void; stayoutside anon_eventsraised aCompileErrorwith no file and no line, unlike the three other compile-time checks. A check whose message points at nothing stops helping the author — and in a package, "points at nothing" reads as "points somewhere inside the dependency".
Divergences kept on purpose
The reconciliation with the TypeScript implementation left two differences
standing, both recorded in
spec/fsl-js-ts.md
§12 rather than resolved: the pending queue, which the BEAM's selective receive
makes unnecessary here, and a few details of stay and goto back.