v2.0.0 — Layered purity enforcement (mostly invisible)
Quick update on where Crank has gone since the v1.1 Moore redesign.
In v1.1, the central claim was that turn/3 is pure: same inputs, same outputs, no side effects. That claim was a sentence in the docs. It worked for the careful reader. It did not work for the third contributor on a tired Friday who slips a Repo.get/2 into a guard clause. v2.0.0 (released 2026-05-04) is mostly about taking that sentence out of the prose and putting it in the toolchain. If your turn/3 is genuinely pure, you won’t notice any of it.
So: how do you actually check that a function is pure? Suppose you sit down to write the checker. The first thing you’d try is to read the code and look for the obvious tells — calls to Process.send/2, :ets.insert/2, IO.puts/1, :rand.uniform/0, and so on. That’s exactly what the first layer does. A @before_compile hook walks the AST of every turn/3 clause and rejects calls to anything on a known-impure blacklist. A paired Credo check uses the same blacklist (single source-of-truth, so the two never disagree) to surface the same violations as warnings during editing. It also flags _ = local_call(...) — a discarded return from a local call is a static tell that the call exists for side effects, which the blacklist can’t otherwise see through. Hard CompileError at compile time. Most mistakes die here.
But you can defeat that checker without trying. You write a function called update_total/1 that looks innocent, and it calls Repo.update/1. The blacklist sees a call to update_total/1, which isn’t on the list, and shrugs. The impurity is real but hidden one level down.
The way to catch that is to stop looking at individual call sites and start looking at the module graph. If your machine module is forbidden from depending on MyApp.Repo at all — directly or transitively, through any chain of helpers — then the helper trick stops working, because the helper itself can’t compile against Repo either. This is what Boundary does, and as of v2.0 it’s a hard dep. mix crank.gen.config writes the starter config that draws the :domain / :infrastructure cut. The two layers are complementary: the first catches obvious impurity at the spot it appears; the second catches structural impurity that’s been hidden behind a polite name.
You can still defeat both of them. Static analysis doesn’t see through dynamic dispatch, apply/3, or metaprogramming that constructs a module name at runtime. So the third layer steps outside static analysis entirely and watches the function while it runs. Crank.PurityTrace runs turn/3 inside an isolated :trace.session_create/3 session — the OTP 26 session-scoped tracing API — and reports any blacklisted call anywhere in the dynamic call graph. Crank.PropertyTest.assert_pure_turn/3 wires this into StreamData so every property test you already have becomes a purity test as well. One helper turns “did this clause produce the right state” into “did this clause produce the right state and touch nothing it shouldn’t have.” This is why OTP 26+ is now required: the older :erlang.trace/3 API leaks across processes and isn’t sound for this job.
Three layers, three different things they can see, three different things they can miss. The layers compose: each catches the holes the others can’t.
Suppression has to follow the same shape, because each layer observes violations differently. Source comments (# crank-allow: CRANK_PURITY_001 # reason: ...) handle AST-level violations. Boundary :exceptions entries handle topology. Programmatic :allow opts on the property test handle runtime trace observations. Try to suppress a topology violation with a source comment and you get CRANK_META_004 pointing you at the right place — conflating the layers was the most common failure mode in drafts. Every code (CRANK_PURITY_001, CRANK_DEP_002, CRANK_TYPE_003, …) is frozen and has a per-code doc page under guides/violations/.
There’s also a type layer worth describing on its own, because it sits parallel to all of this rather than inside it. The macro form looks like:
use Crank,
states: [Drafting, Priced, Placed, Confirmed, Cancelled],
memory: MyApp.OrderMemory
It’s opt-in. What it gives you: a closed state/0 union so Dialyzer can check turn/3 returns; a refusal to let function/0, module/0, or pid() appear in state or memory typespecs (each of those silently breaks snapshot/restore); and a compile-time check that every turn/3 return is one of the declared states. The thinking behind it is that if you tighten the shape of your state — one struct per state, each carrying only the fields valid in that state — most of the enforcement falls out of Elixir’s compiler and Dialyzer for free, with no Crank-specific runtime cost. The macro form is forward-compatible with set-theoretic exhaustiveness: declare the closed union now, get exhaustive turn/3 warnings as the language work matures. Full discipline in the typing-state-and-memory guide.
The whole thing surfaces to users through two mix tasks. mix crank.gen.config is the one-time setup — it wires :crank into compilers:, writes the starter Boundary config, amends .credo.exs. Idempotent. mix crank.check is the single CI gate — it wraps compile --warnings-as-errors, credo --strict, dialyzer, the Boundary check, and the property-test suite into one command, non-zero on any failure. Those two are the surfaces you actually touch. Everything else — the trace sessions, the AST walker, the topology integration, the violation catalog, the suppression routing — sits behind them.
One caveat on adoption: the runtime trace layer is the one least battle-tested outside the example suite. If you try it on a real machine and hit something the OTP 26 trace session does that I haven’t anticipated, please open an issue — that’s the most useful feedback I can get right now.