<?xml version="1.0" encoding="UTF-8" ?>
<?xml-stylesheet type="text/xsl" href="/rss.xsl" media="all"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Roastidio.us Tagged with elixir</title>
<link>https://roastidio.us/tag/2771</link>
<atom:link href="https://roastidio.us/tagged_with/elixir" rel="self" type="application/rss+xml"></atom:link>
<description>Roastidio.us Tagged with elixir</description>
<item>
<title>Implementing a Phoenix PubSub Adapter with EventStore</title>
<link>https://www.erlang-solutions.com/blog/how-to-write-a-phoenix-pubsub-adapter-tutorial-example-based-on-eventstore/</link>
<guid isPermaLink="false">AcdyF1aW7zVklE7eupmSbtsu6qt0jKFTJNBX_w==</guid>
<pubDate>Fri, 18 Sep 2026 19:15:09 +0000</pubDate>
<description>Brian Underwood explains how to build a Phoenix PubSub adapter backed by EventStore. The post Implementing a Phoenix PubSub Adapter with EventStore appeared first on Erlang Solutions.</description>
<content:encoded>&lt;p&gt;Distributed systems need async message delivery across nodes. Phoenix provides &lt;a href=&quot;https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html&quot;&gt;Phoenix PubSub&lt;/a&gt; for this, with pluggable adapters for different backends — officially PG2 and Redis.&lt;/p&gt;&lt;p&gt;This post walks through implementing a Phoenix PubSub adapter backed by &lt;a href=&quot;https://hexdocs.pm/eventstore/EventStore.html&quot;&gt;EventStore&lt;/a&gt;, an Elixir event sourcing library that persists events to PostgreSQL as an append-only log.&lt;/p&gt;&lt;p&gt;Using EventStore as a PubSub backend has a few advantages over the default PG2 adapter:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;No Erlang distribution required&lt;/strong&gt;: nodes communicate through the shared database rather than through the Erlang cluster, so you can run multiple nodes without configuring Erlang node connectivity.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Persistence&lt;/strong&gt;: every broadcast is stored and can be replayed or audited later.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The tradeoffs are the need for storage and the additional latency of a database round-trip per broadcast, making it best suited for lower-throughput messaging where persistence and cross-node decoupling matter more than raw speed. This implementation is a proof of concept — no load tests were performed.&lt;br/&gt;&lt;/p&gt;&lt;p&gt;A full implementation of the adapter can be found &lt;a href=&quot;https://github.com/esl/phoenix_pubsub_eventstore&quot;&gt;on Github&lt;/a&gt;.&lt;br/&gt;&lt;/p&gt;&lt;h2&gt;Phoenix.PubSub.Adapter in a nutshell&lt;/h2&gt;&lt;p&gt;A Phoenix PubSub adapter must implement a few callbacks specified in &lt;code&gt;Phoenix.PubSub.Adapter:&lt;/code&gt;&lt;/p&gt;&lt;div&gt;&lt;pre&gt;node_name(adapter_name)&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Returns the node name as an atom or binary. Used mainly by &lt;code&gt;Phoenix.Tracker.&lt;/code&gt;In most cases:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;node_name(&lt;span&gt;nil&lt;/span&gt;),&lt;span&gt;do&lt;/span&gt;:node()
&lt;span&gt;def&lt;/span&gt;node_name(configured_name),&lt;span&gt;do&lt;/span&gt;:configured_name&lt;/pre&gt;&lt;/div&gt;&lt;div&gt;&lt;pre&gt;child_spec(keyword)&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Generates the child spec for the adapter. GenServer provides a default; this rarely needs overriding.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;broadcast(adapter_name,topic,message,dispatcher)&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Called when a message is broadcast through&lt;code&gt;Phoenix.PubSub.broadcast&lt;/code&gt;. The &lt;code&gt;adapter_name&lt;/code&gt; is the PubSub name with &lt;code&gt;.Adapter&lt;/code&gt; appended (e.g. &lt;code&gt;MyApp.PubSub → MyApp.PubSub.Adapter&lt;/code&gt;). The &lt;code&gt;dispatcher&lt;/code&gt; module handles local delivery via &lt;code&gt;dispatch/3&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;direct_broadcast(adapter_name,node_name,topic,message,dispatcher)&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Same as &lt;code&gt;broadcast/4&lt;/code&gt; with an additional &lt;code&gt;node_name&lt;/code&gt; — the message should only reach subscribers on that node.&lt;/p&gt;&lt;h2&gt;The EventStore adapter&lt;/h2&gt;&lt;p&gt;This section walks through a possible implementation of a Phoenix PubSub adapter that uses EventStore to distribute messages between nodes. This gives a solution that does not depend on Erlang/Elixir distribution, and an event log is stored in case further analysis is needed.&lt;/p&gt;&lt;h3&gt;How Phoenix.PubSub works&lt;/h3&gt;&lt;p&gt;&lt;br/&gt;Phoenix.PubSub uses Elixir’s &lt;a href=&quot;https://hexdocs.pm/elixir/Registry.html&quot;&gt;Registry&lt;/a&gt; for subscriptions — each &lt;code&gt;subscribe&lt;/code&gt; call registers an entry under the topic key. When &lt;code&gt;broadcast&lt;/code&gt; is called, the framework invokes the adapter callback to distribute the message, then handles local dispatch.&lt;/p&gt;&lt;p&gt;The adapter’s job is to get the message to other nodes. For&lt;code&gt;direct_broadcast&lt;/code&gt;, only subscribers on the target node should receive it.&lt;/p&gt;&lt;h2&gt;The implementation&lt;/h2&gt;&lt;p&gt;The adapter is a GenServer that joins the PubSub supervision tree. An&lt;code&gt;eventstore&lt;/code&gt; option selects which EventStore module to use (in case you have multiple):&lt;/p&gt;&lt;div&gt;&lt;pre&gt;{&lt;span&gt;Phoenix.PubSub&lt;/span&gt;,
[&lt;span&gt;name&lt;/span&gt;:&lt;span&gt;MyApp.PubSub&lt;/span&gt;,
&lt;span&gt;adapter&lt;/span&gt;:&lt;span&gt;Phoenix.PubSub.EventStore&lt;/span&gt;,
&lt;span&gt;eventstore&lt;/span&gt;:&lt;span&gt;MyApp.EventStore&lt;/span&gt;]
}&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The GenServer stores the EventStore module and the PubSub name in state — both are needed later:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;defmodule&lt;/span&gt;&lt;span&gt;Phoenix.PubSub.EventStore&lt;/span&gt;&lt;span&gt;do&lt;/span&gt;&lt;span&gt;@behaviour&lt;/span&gt;&lt;span&gt;Phoenix.PubSub.Adapter&lt;/span&gt;&lt;span&gt;use&lt;/span&gt;&lt;span&gt;GenServer&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;start_link(opts)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;GenServer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;start_link(&lt;span&gt;__MODULE__&lt;/span&gt;,opts,&lt;span&gt;name&lt;/span&gt;:opts[&lt;span&gt;:adapter_name&lt;/span&gt;])
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;init(opts)&lt;span&gt;do&lt;/span&gt;{&lt;span&gt;:ok&lt;/span&gt;,
%{
&lt;span&gt;eventstore&lt;/span&gt;:opts[&lt;span&gt;:eventstore&lt;/span&gt;],
&lt;span&gt;pubsub_name&lt;/span&gt;:opts[&lt;span&gt;:name&lt;/span&gt;]
}}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;#... implementation will come here ...#&lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note the difference between &lt;code&gt;opts[:name]&lt;/code&gt;and &lt;code&gt;opts[:adapter_name]&lt;/code&gt;. The former is the name of the PubSub as a whole and is reserved for the Registry. Publishers use it when broadcasting messages. &lt;code&gt;opts[:adapter_name]&lt;/code&gt;can be used as the name of the GenServer.&lt;/p&gt;&lt;h2&gt;Distributing a message as an event&lt;/h2&gt;&lt;p&gt;The GenServer appends a new event to the EventStore when &lt;code&gt;broadcast&lt;/code&gt; is called:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;broadcast(server,topic,message,dispatcher,metadata\\%{})&lt;span&gt;do&lt;/span&gt;metadata&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;put(metadata,&lt;span&gt;:dispatcher&lt;/span&gt;,dispatcher)
&lt;span&gt;GenServer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;call(server,{&lt;span&gt;:broadcast&lt;/span&gt;,topic,message,metadata})
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_call(
{&lt;span&gt;:broadcast&lt;/span&gt;,topic,message,metadata},
_from_pid,
%{&lt;span&gt;id&lt;/span&gt;:id,&lt;span&gt;eventstore&lt;/span&gt;:eventstore,&lt;span&gt;serializer&lt;/span&gt;:serializer,&lt;span&gt;pubsub_name&lt;/span&gt;:pubsub_name}&lt;span&gt;=&lt;/span&gt;state
)&lt;span&gt;do&lt;/span&gt;event&lt;span&gt;=&lt;/span&gt;%&lt;span&gt;EventStore.EventData&lt;/span&gt;{
&lt;span&gt;# ... constructed below&lt;/span&gt;}

res&lt;span&gt;=&lt;/span&gt;eventstore&lt;span&gt;.&lt;/span&gt;append_to_stream(topic,&lt;span&gt;:any_version&lt;/span&gt;,[event])

&lt;span&gt;# For direct_broadcast targeting the current node, the framework does not&lt;/span&gt;&lt;span&gt;# call local dispatch, so the adapter must do it. For regular broadcast,&lt;/span&gt;&lt;span&gt;# the framework handles local dispatch after adapter.broadcast returns :ok.&lt;/span&gt;current_node&lt;span&gt;=&lt;/span&gt;to_string(node())
destination_node&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(metadata,&lt;span&gt;:destination_node&lt;/span&gt;)

&lt;span&gt;if&lt;/span&gt;destination_node&lt;span&gt;==&lt;/span&gt;current_node&lt;span&gt;do&lt;/span&gt;dispatcher&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(metadata,&lt;span&gt;:dispatcher&lt;/span&gt;,&lt;span&gt;Phoenix.PubSub&lt;/span&gt;)
&lt;span&gt;Phoenix.PubSub&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;local_broadcast(pubsub_name,topic,message,dispatcher)
&lt;span&gt;end&lt;/span&gt;{&lt;span&gt;:reply&lt;/span&gt;,res,state}
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;direct_broadcast/5&lt;/code&gt; is a thin wrapper that sets &lt;code&gt;destination_node&lt;/code&gt; in the metadata before delegating to &lt;code&gt;broadcast/5&lt;/code&gt;:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;direct_broadcast(server,node_name,topic,message,dispatcher)&lt;span&gt;do&lt;/span&gt;metadata&lt;span&gt;=&lt;/span&gt;%{
&lt;span&gt;destination_node&lt;/span&gt;:to_string(node_name),
&lt;span&gt;source_node&lt;/span&gt;:to_string(node())
}
broadcast(server,topic,message,dispatcher,metadata)
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;source_node&lt;/code&gt; is stored in the event metadata for auditing. Routing is handled downstream by comparing &lt;code&gt;destination_node&lt;/code&gt; against the current node.&lt;/p&gt;&lt;p&gt;The key decision is how to wrap the message inside &lt;code&gt;%EventStore.EventData{}&lt;/code&gt;. Serialization is handled by a pluggable module (defaulting to &lt;code&gt;Phoenix.PubSub.EventStore.Serializer.Base64&lt;/code&gt;) so the adapter is not tied to a specific encoding. The default serializer base64-encodes &lt;code&gt;:erlang.term_to_binary/&lt;/code&gt;1 output — this is necessary because EventStore stores data as JSON and raw binaries would be invalid, and because JSON cannot distinguish atoms from strings so a round-trip through term serialization preserves type fidelity.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;event&lt;span&gt;=&lt;/span&gt;%&lt;span&gt;EventStore.EventData&lt;/span&gt;{
&lt;span&gt;event_type&lt;/span&gt;:to_string(serializer),
&lt;span&gt;data&lt;/span&gt;:serializer&lt;span&gt;.&lt;/span&gt;serialize(message)
}&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A custom serializer can be provided via the &lt;code&gt;serializer&lt;/code&gt;option as long as it implements &lt;code&gt;serialize/1&lt;/code&gt;and &lt;code&gt;deserialize/1&lt;/code&gt;. &lt;/p&gt;&lt;h2&gt;Handling events, local distribution&lt;/h2&gt;&lt;p&gt;Now that events are in the event store, any subscribed process will receive them. The GenServer must subscribe to all topics (&lt;code&gt;&amp;quot;$all&amp;quot;&lt;/code&gt;). If the event store is also used for another purpose, it’s best to have a separate one for PubSub. The subscription is set up via &lt;code&gt;handle_continue/2&lt;/code&gt;, which runs immediately after&lt;code&gt;init/1&lt;/code&gt; completes, before any other messages can be processed. &lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;handle_continue(&lt;span&gt;:subscribe&lt;/span&gt;,%{&lt;span&gt;eventstore&lt;/span&gt;:eventstore}&lt;span&gt;=&lt;/span&gt;state)&lt;span&gt;do&lt;/span&gt;eventstore&lt;span&gt;.&lt;/span&gt;subscribe(&lt;span&gt;&amp;quot;$all&amp;quot;&lt;/span&gt;)

{&lt;span&gt;:noreply&lt;/span&gt;,state}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_info({&lt;span&gt;:subscribed&lt;/span&gt;,_subscription},state),&lt;span&gt;do&lt;/span&gt;:{&lt;span&gt;:noreply&lt;/span&gt;,state}&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A transient subscription is used since previous messages are not needed. The event store replies with a {&lt;code&gt;:subscribed, subscription&lt;/code&gt;} message, which must also be handled. After this, the server will start receiving {&lt;code&gt;:events, events&lt;/code&gt;} messages.&lt;/p&gt;&lt;p&gt;To avoid dispatching a local message twice (once from &lt;code&gt;broadcast&lt;/code&gt; and once when the event arrives back from EventStore), a unique ID is added to the process state: &lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;init(opts)&lt;span&gt;do&lt;/span&gt;{&lt;span&gt;:ok&lt;/span&gt;,
%{
&lt;span&gt;id&lt;/span&gt;:generate_unique_id(opts),
&lt;span&gt;eventstore&lt;/span&gt;:opts[&lt;span&gt;:eventstore&lt;/span&gt;],
&lt;span&gt;pubsub_name&lt;/span&gt;:opts[&lt;span&gt;:name&lt;/span&gt;],
&lt;span&gt;serializer&lt;/span&gt;:opts[&lt;span&gt;:serializer&lt;/span&gt;]&lt;span&gt;||&lt;/span&gt;&lt;span&gt;Phoenix.PubSub.EventStore.Serializer.Base64&lt;/span&gt;},{&lt;span&gt;:continue&lt;/span&gt;,&lt;span&gt;:subscribe&lt;/span&gt;}}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;generate_unique_id(opts)&lt;span&gt;do&lt;/span&gt;unique_id_fn&lt;span&gt;=&lt;/span&gt;opts[&lt;span&gt;:unique_id_fn&lt;/span&gt;]&lt;span&gt;||&lt;/span&gt;&lt;span&gt;fn&lt;/span&gt;_name&lt;span&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt;UUID&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;uuid4()&lt;span&gt;end&lt;/span&gt;unique_id_fn&lt;span&gt;.&lt;/span&gt;(opts[&lt;span&gt;:name&lt;/span&gt;])
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A custom ID generator can be provided via &lt;code&gt;unique_id_fn&lt;/code&gt; — a function that receives the PubSub name and returns a unique string. Useful when UUID is unavailable or when a deterministic ID is needed for testing.&lt;/p&gt;&lt;p&gt;The&lt;code&gt;id&lt;/code&gt; is added to the event’s metadata field as &lt;code&gt;source_id&lt;/code&gt;, keeping it separate from the message data. Serialization is delegated to the configurable &lt;code&gt;serializer&lt;/code&gt; module. The &lt;code&gt;handle_call&lt;/code&gt; for &lt;code&gt;:broadcast&lt;/code&gt; becomes: &lt;/p&gt;&lt;div&gt;&lt;pre&gt;event&lt;span&gt;=&lt;/span&gt;%&lt;span&gt;EventStore.EventData&lt;/span&gt;{
&lt;span&gt;event_type&lt;/span&gt;:to_string(serializer),
&lt;span&gt;data&lt;/span&gt;:serializer&lt;span&gt;.&lt;/span&gt;serialize(message),
&lt;span&gt;metadata&lt;/span&gt;:&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;put(metadata,&lt;span&gt;:source_id&lt;/span&gt;,id)
}&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Where the value of id and &lt;code&gt;serializer&lt;/code&gt; come from the state, and &lt;code&gt;metadata&lt;/code&gt; already contains &lt;code&gt;dispatcher&lt;/code&gt; and any &lt;code&gt;destination_node&lt;/code&gt; for direct broadcasts. When an event arrives back, &lt;code&gt;source_id&lt;/code&gt; identifies the origin node so duplicates can be skipped:  &lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;handle_info({&lt;span&gt;:events&lt;/span&gt;,events},state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;Enum&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;each(events,&lt;span&gt;&amp;amp;&lt;/span&gt;local_broadcast_event(&lt;span&gt;&amp;amp;1&lt;/span&gt;,state))

{&lt;span&gt;:noreply&lt;/span&gt;,state}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;local_broadcast_event(
%&lt;span&gt;EventStore.RecordedEvent&lt;/span&gt;{
&lt;span&gt;data&lt;/span&gt;:data,
&lt;span&gt;metadata&lt;/span&gt;:metadata,
&lt;span&gt;stream_uuid&lt;/span&gt;:topic,
&lt;span&gt;eventbies_type&lt;/span&gt;:event_type
},
%{&lt;span&gt;id&lt;/span&gt;:id,&lt;span&gt;serializer&lt;/span&gt;:serializer,&lt;span&gt;pubsub_name&lt;/span&gt;:pubsub_name}&lt;span&gt;=&lt;/span&gt;_state
)&lt;span&gt;do&lt;/span&gt;current_node&lt;span&gt;=&lt;/span&gt;to_string(node())

%{&lt;span&gt;source_id&lt;/span&gt;:source_id,&lt;span&gt;destination_node&lt;/span&gt;:destination_node,&lt;span&gt;dispatcher&lt;/span&gt;:dispatcher}&lt;span&gt;=&lt;/span&gt;convert_metadata_keys_to_atoms(metadata)

is_destination?&lt;span&gt;=&lt;/span&gt;is_nil(destination_node)&lt;span&gt;or&lt;/span&gt;destination_node&lt;span&gt;==&lt;/span&gt;current_node

&lt;span&gt;if&lt;/span&gt;&lt;span&gt;not&lt;/span&gt;is_nil(dispatcher)&lt;span&gt;and&lt;/span&gt;is_destination?&lt;span&gt;and&lt;/span&gt;source_id&lt;span&gt;!=&lt;/span&gt;id&lt;span&gt;and&lt;/span&gt;event_type&lt;span&gt;==&lt;/span&gt;to_string(serializer)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;Phoenix.PubSub&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;local_broadcast(
pubsub_name,
topic,
serializer&lt;span&gt;.&lt;/span&gt;deserialize(data),
maybe_convert_to_existing_atom(dispatcher)
)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That’s it — a complete implementation of Phoenix PubSub using EventStore, including support for &lt;code&gt;direct_broadcast&lt;/code&gt; via the &lt;code&gt;destination_node&lt;/code&gt;metadata field and pluggable serialization.&lt;/p&gt;&lt;p&gt;The complete implementation can be found at &lt;a href=&quot;https://github.com/esl/phoenix_pubsub_eventstore&quot;&gt;esl/phoenix_pubsub_eventstore&lt;/a&gt;.&lt;br/&gt;Need help building reliable distributed systems with Elixir? &lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt;Get in touch with our team&lt;/a&gt;.&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;p&gt;The post &lt;a href=&quot;https://www.erlang-solutions.com/blog/how-to-write-a-phoenix-pubsub-adapter-tutorial-example-based-on-eventstore/&quot;&gt;Implementing a Phoenix PubSub Adapter with EventStore&lt;/a&gt; appeared first on Erlang Solutions.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Python NumPy to Elixir-Nx</title>
<link>https://www.thestackcanary.com/numpy-to-nx/</link>
<enclosure type="image/jpeg" length="0" url="https://www.thestackcanary.com/content/images/2023/12/numpy_to_nx.png"></enclosure>
<guid isPermaLink="false">wktkMqPfDoQRL3LazG8gvK4vStmig7VsdBMFsQ==</guid>
<pubDate>Wed, 09 Sep 2026 15:35:00 +0000</pubDate>
<description>Learn how to leverage existing codebases from the Python ML ecosystem</description>
<content:encoded>&lt;p&gt;For my &lt;a href=&quot;https://elixirforum.com/t/serving-spam-detection-with-xgboost-and-nx-elixirconfus-2023/58203?ref=thestackcanary.com&quot;&gt;ElixirConfUS talk&lt;/a&gt;, I wanted to demonstrate training a spam detection model with in Elixir. A pre-processing step I needed to perform was &lt;a href=&quot;https://en.wikipedia.org/wiki/Tf%E2%80%93idf?ref=thestackcanary.com&quot;&gt;TF-IDF vectorization&lt;/a&gt;, but there were no TF-IDF libraries already written which were built for &lt;a href=&quot;https://github.com/elixir-nx/nx/tree/main/nx?ref=thestackcanary.com#readme&quot;&gt;Elixir-Nx&lt;/a&gt;. Seeing as TF-IDF is an extremely common pre-processing step with Decision Trees, since they ingest tabular data, I decided to go ahead and write a full-fledged implementation rather than just writing a minimal implementation that I needed for my example. &lt;/p&gt;&lt;p&gt;I decided to model my implementation after the &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html?ref=thestackcanary.com#sklearn.feature_extraction.text.TfidfVectorizer&quot;&gt;TF-IDF Vectorizer &lt;/a&gt;implementation in the Python scikit-learn, and in writing it I learned many lessons about translating Python NumPy code to Elixir-Nx.  Since Nx is to Elixir as NumPy is to Python, I thought others might find it useful to see how we can leverage existing code from the Python ecosystem to bring it Elixir.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;The sklearn source code can be found &lt;a href=&quot;https://github.com/scikit-learn/scikit-learn/blob/d99b728b3/sklearn/feature_extraction/text.py?ref=thestackcanary.com&quot;&gt;&lt;strong&gt;here&lt;/strong&gt;&lt;/a&gt;, while the Elixir source code can be found &lt;a href=&quot;https://github.com/acalejos/mighty/tree/main/lib/preprocessing?ref=thestackcanary.com&quot;&gt;&lt;strong&gt;here&lt;/strong&gt;&lt;/a&gt;. &lt;/strong&gt;&lt;/p&gt;&lt;h2&gt;API Overview&lt;/h2&gt;&lt;p&gt;A primary goal I had while writing my Elixir library was to make the API as similar to the Python API as possible so that it would be easy for people coming from Python, since sklearn is the Machine Learning library that most people likely have experience with.  The Elixir version supports most of the same arguments as the sklearn version. Here are some examples of basic usage of the API &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;# Elixir
TfidfVectorizer.new(
    ngram_range: {1, 3},
    sublinear_tf: true,
    stop_words: english_stop_words,
    max_features: 5000
  )
  |&amp;gt; TfidfVectorizer.fit_transform(X_train)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Python
TfidfVectorizer(
  sublinear_tf=True, 
  ngram_range=(1, 3), 
  max_features=5000
  ).fit_transform(X_train)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Design Overview&lt;/h2&gt;&lt;p&gt;We will start by looking at the Sklearn &lt;code&gt;CountVectorizer&lt;/code&gt; class since &lt;code&gt;TfidfFVectorizer&lt;/code&gt; is actually implemented as a subclass of &lt;code&gt;CountVectorizer&lt;/code&gt; and then using the &lt;code&gt;fit&lt;/code&gt; method of the &lt;code&gt;TfidfTransformer&lt;/code&gt; class to  transform the output of the &lt;code&gt;CountVectorizer&lt;/code&gt; to its TF-IDF representation. As a result, the bulk of the code is implemented in the &lt;code&gt;CountVectorizer&lt;/code&gt;. In Elixir, I accomplished this design by having a &lt;code&gt;CountVectorizer&lt;/code&gt; module and a &lt;code&gt;TfidfVectorizer&lt;/code&gt; module which has a &lt;code&gt;CountVectorizer&lt;/code&gt; as a struct member. &lt;/p&gt;&lt;p&gt;The vectorizer works by building a vocabulary from the given corpus (or using a vocabulary you pass it), counting the number of times each word in the vocabulary is taken, and filtering according to &lt;/p&gt;&lt;p&gt;The vectorizers work roughly according to these steps:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;Either builds a vocabulary from the given corpus or uses a vocabulary supplied to it.&lt;br/&gt;
a. Performs preprocessing according to a given function&lt;br/&gt;
b. Performs tokenization according to a tokenization function&lt;br/&gt;
c. Generates requested ngrams&lt;br/&gt;
d. Filters stop words&lt;/li&gt;
&lt;li&gt;Iterates through each document in the corpus, counting each term in the vocabulary&lt;/li&gt;
&lt;li&gt;Limit output features according to parameters&lt;br/&gt;
a. max_features - Only consider the top &lt;code&gt;max_features&lt;/code&gt; ordered by term frequency across the corpus.&lt;br/&gt;
b. min_df - Ignore terms that have a document frequency strictly lower than the given threshold.&lt;br/&gt;
c. Ignore terms that have a document frequency strictly higher than the given threshold.&lt;/li&gt;
&lt;li&gt;Output &lt;code&gt;CountVectorizer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TfidfVectorizer&lt;/code&gt; uses the &lt;code&gt;CountVectorizer&lt;/code&gt; to transform the term-frequency matrix into it TFIDF Matrix.&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;As you can see, the bulk of the work is done in the &lt;code&gt;CountVectorizer&lt;/code&gt; module, so that is where we will spend the most of our time going forward. Now that you have a general understanding of how the vectorizers work, we will look at a brief survey of different functions to compare Python and Elixir implementations. &lt;/p&gt;&lt;h2&gt;Implementation Details&lt;/h2&gt;&lt;p&gt;Here is how the vectorizer is initialized in Python. It uses keyword args with default parameters and initializes its class attributes accordingly. &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class CountVectorizer(_VectorizerMixin, BaseEstimator):
  def __init__(
        self,
        *,
        input=&amp;quot;content&amp;quot;,
        encoding=&amp;quot;utf-8&amp;quot;,
        decode_error=&amp;quot;strict&amp;quot;,
        strip_accents=None,
        lowercase=True,
        preprocessor=None,
        tokenizer=None,
        stop_words=None,
        token_pattern=r&amp;quot;(?u)\b\w\w+\b&amp;quot;,
        ngram_range=(1, 1),
        analyzer=&amp;quot;word&amp;quot;,
        max_df=1.0,
        min_df=1,
        max_features=None,
        vocabulary=None,
        binary=False,
        dtype=np.int64,
    )
    self.input = input
        self.encoding = encoding
        self.decode_error = decode_error
        self.strip_accents = strip_accents
        self.preprocessor = preprocessor
        self.tokenizer = tokenizer
        self.analyzer = analyzer
        self.lowercase = lowercase
        self.token_pattern = token_pattern
        self.stop_words = stop_words
        self.max_df = max_df
        self.min_df = min_df
        self.max_features = max_features
        self.ngram_range = ngram_range
        self.vocabulary = vocabulary
        self.binary = binary
        self.dtype = dtype&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And here is a snippet of the &lt;code&gt;CountVectorizer&lt;/code&gt; struct in Elixir as well as the &lt;code&gt;new/1&lt;/code&gt; function that is used to initialize the vectorizer. &lt;code&gt;new/1&lt;/code&gt; gives a similar behavior to Python&amp;#39;s class &lt;code&gt;init&lt;/code&gt; method.  I used &lt;code&gt;NimbleOptions&lt;/code&gt; for parameter validation (it&amp;#39;s a great library and you can read more about it &lt;a href=&quot;https://www.thestackcanary.com/elixir-nimble-options/&quot;&gt;here&lt;/a&gt;), and you can refer to the parameters source &lt;a href=&quot;https://github.com/acalejos/mighty/blob/main/lib/preprocessing/shared.ex?ref=thestackcanary.com#L3&quot;&gt;here&lt;/a&gt;.  &lt;code&gt;validate_shared!&lt;/code&gt; validates the parameters and assigns default value when none were provided. &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule Mighty.Preprocessing.CountVectorizer do
  defstruct vocabulary: nil,
            ngram_range: {1, 1},
            max_features: nil,
            min_df: 1,
            max_df: 1.0,
            stop_words: [],
            binary: false,
            preprocessor: nil,
            tokenizer: nil,
            pruned: nil

  def new(opts \\ []) do
    opts = Mighty.Preprocessing.Shared.validate_shared!(opts)
    struct(__MODULE__, opts)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;All of the operations in our &lt;code&gt;CountVectorizer&lt;/code&gt; module will take a &lt;code&gt;CountVectorizer&lt;/code&gt; as its first argument, which allows us to pipe our operations nicely. The Python implementation separates the &lt;code&gt;TfidfTransformer&lt;/code&gt; from the &lt;code&gt;TfidfVectorizer&lt;/code&gt;, where the &lt;code&gt;TFIDFVectorizer&lt;/code&gt; inherits from the &lt;code&gt;CountVectorizer&lt;/code&gt;. To achieve similar behavior, our &lt;code&gt;TfidfVectorizer&lt;/code&gt; is its own struct that contains a &lt;code&gt;CountVectorizer&lt;/code&gt; as one of its member. Creating a new &lt;code&gt;TfidfVectorizer&lt;/code&gt; starts with creating a new &lt;code&gt;CountVectorizer&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule Mighty.Preprocessing.TfidfVectorizer do
  alias Mighty.Preprocessing.CountVectorizer
  alias Mighty.Preprocessing.Shared

  defstruct [
    :count_vectorizer,
    :norm,
    :idf,
    use_idf: true,
    smooth_idf: true,
    sublinear_tf: false
  ]

  @doc &amp;quot;&amp;quot;&amp;quot;
  Creates a new `TfidfVectorizer` struct with the given options.

  Returns the new vectorizer.
  &amp;quot;&amp;quot;&amp;quot;
  def new(opts \\ []) do
    {general_opts, tfidf_opts} =
      Keyword.split(opts, Shared.get_vectorizer_schema() |&amp;gt; Keyword.keys())

    count_vectorizer = CountVectorizer.new(general_opts)
    tfidf_opts = Shared.validate_tfidf!(tfidf_opts)

    %__MODULE__{count_vectorizer: count_vectorizer}
    |&amp;gt; struct(tfidf_opts)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now, let&amp;#39;s compare three of the main pieces of functionality between the two implementations: &lt;strong&gt;building the term-frequency matrix&lt;/strong&gt; (count matrix), &lt;strong&gt;limiting / pruning features&lt;/strong&gt; from the resulting matrix, and performing the &lt;strong&gt;TFIDF transformation&lt;/strong&gt; on that resulting matrix.&lt;/p&gt;&lt;h2&gt;Building Term-Frequency Matrix&lt;/h2&gt;&lt;div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defp _transform(vectorizer = %__MODULE__{}, corpus, n_doc) do
  if is_nil(vectorizer.vocabulary) do
    raise &amp;quot;CountVectorizer must be fit to a corpus before transforming the corpus. Use CountVectorizer.fit/2 or CountVectorizer.fit_transform/2 to fit the CountVectorizer to a corpus.&amp;quot;
  end

  tf = Nx.broadcast(0, {n_doc, Enum.count(vectorizer.vocabulary)})

  corpus
  |&amp;gt; Enum.with_index()
  |&amp;gt; Enum.chunk_every(2000)
  |&amp;gt; Enum.reduce(tf, fn chunk, acc -&amp;gt;
    Task.async_stream(
      chunk,
      fn {doc, doc_idx} -&amp;gt;
        doc
        |&amp;gt; then(&amp;amp;do_process(vectorizer, &amp;amp;1))
        |&amp;gt; Enum.reduce(
          Map.new(vectorizer.vocabulary, fn {k, _} -&amp;gt; {k, 0} end),
          fn token, acc -&amp;gt;
            Map.update(acc, token, 1, &amp;amp;(&amp;amp;1 + 1))
          end
        )
        |&amp;gt; Enum.map(fn {k, v} -&amp;gt;
          case Map.get(vectorizer.vocabulary, k) do
            nil -&amp;gt; nil
            _ when v == 0 -&amp;gt; nil
            idx -&amp;gt; [doc_idx, idx, v]
          end
        end)
      end,
      timeout: :infinity
    )
    |&amp;gt; Enum.reduce({[], []}, fn
      {:ok, iter_result}, acc -&amp;gt;
        Enum.reduce(iter_result, acc, fn
          nil, acc -&amp;gt; acc
          [x, y, z], {idx, upd} -&amp;gt; {[[x, y] | idx], [z | upd]}
        end)
    end)
    |&amp;gt; then(fn {idx, upd} -&amp;gt;
      Nx.indexed_put(acc, Nx.tensor(idx), Nx.tensor(upd))
    end)
  end)
end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _count_vocab(self, raw_documents, fixed_vocab):
  &amp;quot;&amp;quot;&amp;quot;Create sparse feature matrix, and vocabulary where fixed_vocab=False&amp;quot;&amp;quot;&amp;quot;
  if fixed_vocab:
      vocabulary = self.vocabulary_
  else:
      # Add a new value when a new vocabulary item is seen
      vocabulary = defaultdict()
      vocabulary.default_factory = vocabulary.__len__

  analyze = self.build_analyzer()
  j_indices = []
  indptr = []

  values = _make_int_array()
  indptr.append(0)
  for doc in raw_documents:
      feature_counter = {}
      for feature in analyze(doc):
          try:
              feature_idx = vocabulary[feature]
              if feature_idx not in feature_counter:
                  feature_counter[feature_idx] = 1
              else:
                  feature_counter[feature_idx] += 1
          except KeyError:
              # Ignore out-of-vocabulary items for fixed_vocab=True
              continue

      j_indices.extend(feature_counter.keys())
      values.extend(feature_counter.values())
      indptr.append(len(j_indices))

  if not fixed_vocab:
      # disable defaultdict behaviour
      vocabulary = dict(vocabulary)
      if not vocabulary:
          raise ValueError(
              &amp;quot;empty vocabulary; perhaps the documents only contain stop words&amp;quot;
          )

  if indptr[-1] &amp;gt; np.iinfo(np.int32).max:  # = 2**31 - 1
      if _IS_32BIT:
          raise ValueError(
              (
                  &amp;quot;sparse CSR array has {} non-zero &amp;quot;
                  &amp;quot;elements and requires 64 bit indexing, &amp;quot;
                  &amp;quot;which is unsupported with 32 bit Python.&amp;quot;
              ).format(indptr[-1])
          )
      indices_dtype = np.int64

  else:
      indices_dtype = np.int32
  j_indices = np.asarray(j_indices, dtype=indices_dtype)
  indptr = np.asarray(indptr, dtype=indices_dtype)
  values = np.frombuffer(values, dtype=np.intc)

  X = sp.csr_matrix(
      (values, j_indices, indptr),
      shape=(len(indptr) - 1, len(vocabulary)),
      dtype=self.dtype,
  )
  X.sort_indices()
  return vocabulary, X&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The most evident differences here are that in the Elixir code we are building a dense tensor, while the Python code is building a sparse tensor. A sparse tensor certainly makes much more sense in the context of these vectorizers, but as of now Nx currently does not support sparse tensors.  This is also why we are using &lt;code&gt;Task.async_stream&lt;/code&gt; along with &lt;code&gt;Enum.chunk_every&lt;/code&gt;, as to reduce the memory consumption since it is dense. &lt;/p&gt;&lt;p&gt; The way we are constructing the tensor, however, is almost identical.  We start by creating a zero-tensor the size of the final tensor. Then we are creating mappings of indices and their updates during our iteration within the &lt;code&gt;reduce&lt;/code&gt;. After we collect these updates, we update the initial zero-tensor using &lt;code&gt;Nx.indexed_put&lt;/code&gt;, which requires a list of indices you are updating along with the new values you are putting into those indices. &lt;/p&gt;&lt;h2&gt;Feature Pruning&lt;/h2&gt;&lt;div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defp where_columns(condition = %Nx.Tensor{shape: {_cond_len}}) do
    count = Nx.sum(condition) |&amp;gt; Nx.to_number()
    Nx.argsort(condition, direction: :desc) |&amp;gt; Nx.slice_along_axis(0, count, axis: 0)
  end

  defp limit_features(
         vectorizer = %__MODULE__{},
         tf = %Nx.Tensor{},
         df = %Nx.Tensor{shape: {df_len}},
         high,
         low,
         limit
       ) do
    mask = Nx.broadcast(1, {df_len})
    mask = if high, do: Nx.logical_and(mask, Nx.less_equal(df, high)), else: mask
    mask = if low, do: Nx.logical_and(mask, Nx.greater_equal(df, low)), else: mask

    limit =
      case limit do
        0 -&amp;gt;
          limit

        nil -&amp;gt;
          limit

        _ -&amp;gt;
          limit - 1
      end

    mask =
      if limit &amp;amp;&amp;amp; Nx.greater(Nx.sum(mask), limit) do
        tfs = Nx.sum(tf, axes: [0]) |&amp;gt; Nx.flatten()
        orig_mask_inds = where_columns(mask)
        mask_inds = Nx.argsort(Nx.take(tfs, orig_mask_inds) |&amp;gt; Nx.multiply(-1))[0..limit]
        new_mask = Nx.broadcast(0, {df_len})
        new_indices = Nx.take(orig_mask_inds, mask_inds) |&amp;gt; Nx.new_axis(1)
        new_updates = Nx.broadcast(1, {Nx.flat_size(new_indices)})
        new_mask = Nx.indexed_put(new_mask, new_indices, new_updates)

        new_mask
      else
        mask
      end

    new_indices = mask |&amp;gt; Nx.flatten() |&amp;gt; Nx.cumulative_sum() |&amp;gt; Nx.subtract(1)

    {new_vocab, removed_terms} =
      Enum.reduce(vectorizer.vocabulary, {%{}, MapSet.new([])}, fn {term, old_index},
                                                                   {vocab_acc, removed_acc} -&amp;gt;
        case Nx.to_number(mask[old_index]) do
          1 -&amp;gt;
            {Map.put(vocab_acc, term, Nx.to_number(new_indices[old_index])), removed_acc}

          _ -&amp;gt;
            {vocab_acc, MapSet.put(removed_acc, term)}
        end
      end)

    kept_indices = where_columns(mask)

    if Nx.flat_size(kept_indices) == 0 do
      raise &amp;quot;After pruning, no terms remain. Try a lower min_df or a higher max_df.&amp;quot;
    end

    tf = Nx.take(tf, kept_indices, axis: 1)
    {tf, new_vocab, removed_terms}
  end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _limit_features(self, X, vocabulary, high=None, low=None, limit=None):
        if high is None and low is None and limit is None:
            return X, set()

        # Calculate a mask based on document frequencies
        dfs = _document_frequency(X)
        mask = np.ones(len(dfs), dtype=bool)
        if high is not None:
            mask &amp;amp;= dfs &amp;lt;= high if low is not none: mask &amp;amp;=&amp;quot;dfs&amp;quot;&amp;gt;= low
        if limit is not None and mask.sum() &amp;gt; limit:
            tfs = np.asarray(X.sum(axis=0)).ravel()
            mask_inds = (-tfs[mask]).argsort()[:limit]
            new_mask = np.zeros(len(dfs), dtype=bool)
            new_mask[np.where(mask)[0][mask_inds]] = True
            mask = new_mask

        new_indices = np.cumsum(mask) - 1  # maps old indices to new
        removed_terms = set()
        for term, old_index in list(vocabulary.items()):
            if mask[old_index]:
                vocabulary[term] = new_indices[old_index]
            else:
                del vocabulary[term]
                removed_terms.add(term)
        kept_indices = np.where(mask)[0]
        if len(kept_indices) == 0:
            raise ValueError(
                &amp;quot;After pruning, no terms remain. Try a lower min_df or a higher max_df.&amp;quot;
            )
        return X[:, kept_indices], removed_terms&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;&lt;p&gt;These functions show the most stark differences between the capabilities of NumPy versus those of Nx, as well as just the syntactic differences. The syntax of NumPy is much less verbose that that of Nx (especially considering that we are operating outside of a &lt;code&gt;defn&lt;/code&gt; here which will inject its own implementation of the &lt;code&gt;Kernel&lt;/code&gt; module to add custom operators), and there are just some capabilities in NumPy that are currently not possible in Nx. For example, in Nx you &lt;a href=&quot;https://elixirforum.com/t/np-argwhere-operation-equivalent-for-nx-is-this-feature-available-currently-is-there-an-alternative-that-i-m-not-considering/52926/2?u=acalejos&amp;amp;ref=thestackcanary.com&quot;&gt;cannot do dynamic shape modifications&lt;/a&gt; like are done in the Python code &lt;code&gt;new_mask[np.where(mask)[0][mask_inds]] = True&lt;/code&gt;, so I had to come up with other solutions that could achieve the same thing. Looking at the Python version, you realize that &lt;code&gt;np.where(mask)[0]&lt;/code&gt; is only concerned with the resulting columns. This makes sense since each column represents a term in the vocabulary and each row represents a document in the corpus, so each item in a column represents the count of that term in that documents. So we are concerned with whole columns because term-frequency is calculated for each term, which again is represented by the whole column.  So we can now use a combination of our function &lt;code&gt;where_columns&lt;/code&gt; and Nx functions such as &lt;code&gt;Nx.argsort&lt;/code&gt;, &lt;code&gt;Nx.take&lt;/code&gt;, and &lt;code&gt;Nx.multiply&lt;/code&gt; to rearrange the matrix such that items are sorted by our filter conditions, and then we can just take the number of items we want according to the supplied &lt;code&gt;:limit&lt;/code&gt;. &lt;/p&gt;&lt;p&gt;It would take entirely too long for me to go over every difference between these two functions, but I implore you to look closely at these two implementations to gain a better understanding of how to convert NumPy code to Elixir Nx. &lt;/p&gt;&lt;h2&gt;TFIDF Transformation&lt;/h2&gt;&lt;div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def fit(%__MODULE__{count_vectorizer: count_vectorizer} = vectorizer, corpus) do
    {cv, tf} = CountVectorizer.fit_transform(count_vectorizer, corpus)
    df = Scholar.Preprocessing.binarize(tf) |&amp;gt; Nx.sum(axes: [0])

    idf =
      if vectorizer.use_idf do
        {n_samples, _n_features} = Nx.shape(tf)
        df = Nx.add(df, if(vectorizer.smooth_idf, do: 1, else: 0))
        n_samples = if vectorizer.smooth_idf, do: n_samples + 1, else: n_samples
        Nx.divide(n_samples, df) |&amp;gt; Nx.log() |&amp;gt; Nx.add(1)
      end

    struct(vectorizer, count_vectorizer: cv, idf: idf)
  end

  def transform(%__MODULE__{count_vectorizer: count_vectorizer} = vectorizer, corpus) do
    tf = CountVectorizer.transform(count_vectorizer, corpus)

    tf =
      if vectorizer.sublinear_tf do
        Nx.select(Nx.equal(tf, 0), 0, Nx.log(tf) |&amp;gt; Nx.add(1))
      else
        tf
      end

    tf =
      if vectorizer.use_idf do
        unless vectorizer.idf do
          raise &amp;quot;Vectorizer has not been fitted yet. Please call `fit_transform` or `fit` first.&amp;quot;
        end

        Nx.multiply(tf, vectorizer.idf)
      else
        tf
      end

    tf =
      case vectorizer.norm do
        nil -&amp;gt; tf
        norm -&amp;gt; Scholar.Preprocessing.normalize(tf, norm: norm)
      end

    tf
  end

  def fit_transform(%__MODULE__{} = vectorizer, corpus) do
    vectorizer = fit(vectorizer, corpus)
    {vectorizer, transform(vectorizer, corpus)}
  end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TfidfTransformer(
    OneToOneFeatureMixin, TransformerMixin, BaseEstimator, auto_wrap_output_keys=None
):
    def __init__(self, *, norm=&amp;quot;l2&amp;quot;, use_idf=True, smooth_idf=True, sublinear_tf=False):
        self.norm = norm
        self.use_idf = use_idf
        self.smooth_idf = smooth_idf
        self.sublinear_tf = sublinear_tf

    def fit(self, X, y=None):
        X = self._validate_data(
            X, accept_sparse=(&amp;quot;csr&amp;quot;, &amp;quot;csc&amp;quot;), accept_large_sparse=not _IS_32BIT
        )
        if not sp.issparse(X):
            X = sp.csr_matrix(X)
        dtype = X.dtype if X.dtype in FLOAT_DTYPES else np.float64

        if self.use_idf:
            n_samples, n_features = X.shape
            df = _document_frequency(X)
            df = df.astype(dtype, copy=False)

            # perform idf smoothing if required
            df += int(self.smooth_idf)
            n_samples += int(self.smooth_idf)

            # log+1 instead of log makes sure terms with zero idf don&amp;#39;t get
            # suppressed entirely.
            idf = np.log(n_samples / df) + 1
            self._idf_diag = sp.diags(
                idf,
                offsets=0,
                shape=(n_features, n_features),
                format=&amp;quot;csr&amp;quot;,
                dtype=dtype,
            )

        return self

    def transform(self, X, copy=True):
        X = self._validate_data(
            X, accept_sparse=&amp;quot;csr&amp;quot;, dtype=FLOAT_DTYPES, copy=copy, reset=False
        )
        if not sp.issparse(X):
            X = sp.csr_matrix(X, dtype=np.float64)

        if self.sublinear_tf:
            np.log(X.data, X.data)
            X.data += 1

        if self.use_idf:
            # idf_ being a property, the automatic attributes detection
            # does not work as usual and we need to specify the attribute
            # name:
            check_is_fitted(self, attributes=[&amp;quot;idf_&amp;quot;], msg=&amp;quot;idf vector is not fitted&amp;quot;)

            # *= doesn&amp;#39;t work
            X = X * self._idf_diag

        if self.norm is not None:
            X = normalize(X, norm=self.norm, copy=False)

        return X

    @property
    def idf_(self):
        # if _idf_diag is not set, this will raise an attribute error,
        # which means hasattr(self, &amp;quot;idf_&amp;quot;) is False
        return np.ravel(self._idf_diag.sum(axis=0))

    @idf_.setter
    def idf_(self, value):
        value = np.asarray(value, dtype=np.float64)
        n_features = value.shape[0]
        self._idf_diag = sp.spdiags(
            value, diags=0, m=n_features, n=n_features, format=&amp;quot;csr&amp;quot;
        )

  class TfidfVectorizer(CountVectorizer):
      def fit(self, raw_documents, y=None):
        self._check_params()
        self._warn_for_unused_params()
        self._tfidf = TfidfTransformer(
            norm=self.norm,
            use_idf=self.use_idf,
            smooth_idf=self.smooth_idf,
            sublinear_tf=self.sublinear_tf,
        )
        X = super().fit_transform(raw_documents)
        self._tfidf.fit(X)
        return self

    def fit_transform(self, raw_documents, y=None):
        self._check_params()
        self._tfidf = TfidfTransformer(
            norm=self.norm,
            use_idf=self.use_idf,
            smooth_idf=self.smooth_idf,
            sublinear_tf=self.sublinear_tf,
        )
        X = super().fit_transform(raw_documents)
        self._tfidf.fit(X)
        # X is already a transformed view of raw_documents so
        # we set copy to False
        return self._tfidf.transform(X, copy=False)

    def transform(self, raw_documents):
        check_is_fitted(self, msg=&amp;quot;The TF-IDF vectorizer is not fitted&amp;quot;)

        X = super().transform(raw_documents)
        return self._tfidf.transform(X, copy=False)&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;
&lt;/div&gt;&lt;p&gt;I would consider this the most straight-forward translation example between the two implementations. One noticeable difference is that &lt;code&gt;Nx.log&lt;/code&gt; doesn&amp;#39;t handle zero values the same way NumPy does. While NumPy essentially ignores zeroes, &lt;code&gt;Nx.log&lt;/code&gt; will throw a divide by zero error, so I use &lt;code&gt;Nx.select&lt;/code&gt; to selectively ignore zero values, and only apply &lt;code&gt;Nx.log&lt;/code&gt; to non-zero values.  Additionally, I use &lt;code&gt;Nx.multiply(tf, vectorizer.idf)&lt;/code&gt; to achieve the same thing as &lt;code&gt;X = X * self._idf_diag&lt;/code&gt; as there is no need to construct a diagonal matrix since &lt;code&gt;Nx.multiply&lt;/code&gt; broadcasts. &lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;I would have liked to go into more detail for each example, but I think the code does a good job by itself showing the differences and steps required to translate a NumPy implementation to Nx. I think these examples illustrate how NumPy can obscure what operations are happening in an effort to make a more concise syntax, whereas some might consider Nx overly verbose in comparison. The more you familiarize yourself with both APIs, the better you will be able to identify places where you can do direct translation and places where you might have to be more creative. &lt;/p&gt;&lt;p&gt;Comment below with your own examples or if you have any other Python snippets you want to see converted to Elixir Nx!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Texttile, a multiplayer blog engine for people who write together – Klaus Breyer</title>
<link>https://www.v01.io/posts/2026/08/texttile/</link>
<enclosure type="image/jpeg" length="0" url="https://www.v01.io/posts/2026/08/texttile/preview.png"></enclosure>
<guid isPermaLink="false">LkjNl5qDEQAILiDJVUri3_j6FBQsyMIC0pr0rw==</guid>
<pubDate>Tue, 01 Sep 2026 22:31:03 +0000</pubDate>
<description>My wife and I have blogged about every trip since our honeymoon 10 years ago. For the people at home, for ourselves later, and by now for our children. We took turns writing, but both had photos and videos on their phones. The text was never the hard part. The photos and videos were: every day one of us sent them from the phone to the other, who had to upload them and sort them into the entry in the right order.</description>
<content:encoded>&lt;p&gt;My wife and I have blogged about every trip since our honeymoon 10 years ago. For the people at home, for ourselves later, and by now for our children. We took turns writing, but both had photos and videos on their phones.&lt;/p&gt;&lt;p&gt;The text was never the hard part. The photos and videos were: every day one of us sent them from the phone to the other, who had to upload them and sort them into the entry in the right order.&lt;/p&gt;&lt;p&gt;I built &lt;a href=&quot;https://imaedge.org&quot;&gt;imaedge&lt;/a&gt; for that part first, and we tried it on the next trip, in &lt;a href=&quot;https://www.v01.io/posts/2026/08/mexico/&quot;&gt;Mexico&lt;/a&gt; this year. Uploading held up. Tiles arrive in the order the camera gives them, and dragging one changes the order in the gallery.&lt;/p&gt;&lt;p&gt;What was still missing was bringing it together with the text. The family blog ran on WordPress, but WordPress never got this. Not the mobile editing experience, not the video support, not the gallery, and definitely not the “together” part. You are limited to one author per entry (but you get a bunch of plugins nobody maintains and constant bot attacks on your wp-admin).&lt;/p&gt;&lt;p&gt;So after Mexico, with the gallery proven, I did what every programmer apparently has to do once. I wrote my own CMS. It is called &lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt;, it is open source, and it is written in Elixir.&lt;/p&gt;&lt;p&gt;It allows multiple people in the same entry at the same time, uploading and sorting photos AND videos, treated as first-class citizens.&lt;/p&gt;&lt;h2&gt;What it does differently ¶&lt;/h2&gt;&lt;p&gt;In most blog engines only one author can edit an entry. &lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt; rethinks content management as multiplayer.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Text:&lt;/strong&gt; multiple people can have the same entry open. One of them has the text and types, the other watches the words arrive and can take the text over with one click.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Tile:&lt;/strong&gt; both of you drag tiles with photos and videos into place at the same time, and you see each other doing it. The gallery is never locked.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;An entry consists of text and tiles - that is the name.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://www.v01.io/posts/2026/08/texttile/writing-you.png&quot; alt=&quot;Your screen: you write while the other person reads along&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://www.v01.io/posts/2026/08/texttile/writing-other.png&quot; alt=&quot;The other person’s screen showing the same entry&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Both screens show the same entry at the same moment. The writer sees a purple status bar and can edit the text. The other person sees an orange status bar and a read-only editor. Both can still work on the gallery.&lt;/p&gt;&lt;p&gt;Travel shaped the rest:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt; loads nothing from outside. No CDN, no tracker, no captcha, no hosted font, no cookie banner. A reader’s browser talks to your server and nothing else.&lt;/li&gt;&lt;li&gt;It stays light enough for a slow line: small pages, little JavaScript, and pictures only as large as the screen asks for.&lt;/li&gt;&lt;li&gt;Videos come from your own server. Drop one in and &lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt; converts it, thumbnail included. No YouTube embed, no player from anywhere else.&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt; stores your Markdown byte for byte. A version diff shows real edits and nothing else.&lt;/li&gt;&lt;li&gt;One container, one folder. Phoenix, LiveView, ffmpeg and SQLite live in one Docker image. Everything is in &lt;code&gt;/data&lt;/code&gt;. Move that folder and you move the blog.&lt;/li&gt;&lt;li&gt;Comments, a newsletter, and statistics stay on your server. &lt;a href=&quot;https://www.texttile.blog/&quot;&gt;Texttile&lt;/a&gt; uses no cookies and stores no IP addresses.&lt;/li&gt;&lt;li&gt;English and German (more translations are welcome as a contribution!)&lt;/li&gt;&lt;/ul&gt;&lt;h2&gt;What it is not ¶&lt;/h2&gt;&lt;p&gt;There are no roles, no permission matrix, no plugins, no theme marketplace. Everybody with an account is an admin. I built it for people who trust each other, because that is who writes a blog together. My philosophy is that a product is only perfect when there is nothing left to take away.&lt;/p&gt;&lt;h2&gt;Why Elixir ¶&lt;/h2&gt;&lt;p&gt;Multiple people editing one entry at the same time is what the BEAM was made for. The lock is a GenServer, the keystrokes travel over PubSub, and the whole thing runs on one small machine next to a SQLite file. No Redis, no queue, no second service.&lt;/p&gt;&lt;h2&gt;Try it ¶&lt;/h2&gt;&lt;p&gt;Run it on your own machine:&lt;/p&gt;&lt;p&gt;Or start a demo at &lt;a href=&quot;https://www.texttile.blog&quot;&gt;www.texttile.blog&lt;/a&gt;. You get your own blog for 24 hours. If you like it, you can keep it. If not, it goes to sleep and is deleted 30 days later, with everything in it.&lt;/p&gt;&lt;p&gt;I put entries from our own travel blog from Mexico online at &lt;a href=&quot;https://demo.texttile.blog&quot;&gt;demo.texttile.blog&lt;/a&gt;, if you want to see it from the reader’s side.&lt;/p&gt;&lt;p&gt;The code is at &lt;a href=&quot;https://github.com/texttile-blog/texttile&quot;&gt;github.com/texttile-blog/texttile&lt;/a&gt;. Now I am interested in your feedback! How do you blog on the road?&lt;/p&gt;&lt;p&gt;And here it is in action:&lt;/p&gt;&lt;video&gt;
&lt;img src=&quot;https://www.v01.io/posts/2026/08/texttile/writing-poster.jpg&quot; alt=&quot;Two screens, one entry: one person writes, the other reads along and can take over&quot; title=&quot;&quot;/&gt;&lt;/video&gt;</content:encoded>
</item>
<item>
<title>Privacy by Design no JurisOS: Criptografia AES-256 de Ponta a Ponta com Cloak.Ecto, SQLCipher e Tailscale VPN - DEV Community</title>
<link>https://dev.to/web-engenharia/privacy-by-design-no-jurisos-criptografia-aes-256-de-ponta-a-ponta-com-cloakecto-sqlcipher-e-108p</link>
<enclosure type="image/jpeg" length="0" url="https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckqg2xavlz5q4el92hgq.png"></enclosure>
<guid isPermaLink="false">vWDqj2MZBtPUEKh7mtXPCAZ53QUAc4vqqKqeJA==</guid>
<pubDate>Sat, 29 Aug 2026 15:13:10 +0000</pubDate>
<description>Como blindar os dados de um escritório de advocacia em todas as pontas da arquitetura com Elixir, garantindo conformidade com LGPD, GDPR e CCPA.</description>
<content:encoded>&lt;p&gt;O setor jurídico lida diariamente com dados de altíssima sensibilidade: laudos médicos, segredos industriais, estratégias de litígio, dados bancários e informações de identificação pessoal (PII). Desenvolver um sistema como o &lt;strong&gt;JurisOS&lt;/strong&gt; — que opera sob o paradigma &lt;em&gt;Local-First&lt;/em&gt; nos desktops de secretárias e advogados — exige incorporar o conceito de &lt;strong&gt;Privacy by Design&lt;/strong&gt; desde a fundação da arquitetura.&lt;/p&gt;&lt;p&gt;Neste artigo, vamos detalhar como construímos uma malha de defesa em profundidade no ecossistema Elixir e BEAM, utilizando criptografia AES-256-GCM tanto em repouso quanto em trânsito, para garantir conformidade rigorosa com legislações de proteção de dados (LGPD, GDPR e CCPA).&lt;/p&gt;&lt;h2&gt;
  
  
  1. O Desafio da Conformidade (LGPD, GDPR e CCPA)
&lt;/h2&gt;&lt;p&gt;As leis modernas de proteção de dados exigem que os sistemas garantam a confidencialidade e a integridade das informações pessoais contra vazamentos, sejam eles acidentais (perda de um notebook) ou maliciosos (ataques de exfiltração).&lt;/p&gt;&lt;p&gt;Para um aplicativo desktop &lt;em&gt;Local-First&lt;/em&gt; sincronizado com a nuvem, a superfície de ataque é dupla:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;O endpoint local:&lt;/strong&gt; O disco do computador físico do usuário no escritório.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;O transporte e o backend central:&lt;/strong&gt; A rede por onde os dados trafegam e o banco de dados principal (PostgreSQL) na nuvem.&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;Para mitigar esses vetores, o JurisOS adota a &lt;strong&gt;Separação de Chaves (Key Isolation)&lt;/strong&gt; em múltiplas camadas.&lt;/p&gt;&lt;h2&gt;
  
  
  2. Separação de Chaves: SQLCipher e Cloak.Ecto
&lt;/h2&gt;&lt;p&gt;Não basta confiar apenas no acesso do usuário ao sistema operacional. Se um dispositivo for furtado, o banco de dados local não pode ser montado e lido por terceiros. Para resolver isso, separamos as responsabilidades de criptografia.&lt;/p&gt;&lt;h3&gt;
  
  
  Camada 1: Criptografia de Disco com SQLCipher (&lt;code&gt;SQLCIPHER_KEY&lt;/code&gt;)
&lt;/h3&gt;&lt;p&gt;Na ponta do cliente (o aplicativo desktop empacotado via Burrito), o armazenamento local não é um SQLite padrão, mas sim o &lt;strong&gt;SQLCipher&lt;/strong&gt;. Todo o arquivo &lt;code&gt;.db&lt;/code&gt; é cifrado transparentemente utilizando AES-256. &lt;br/&gt;
A &lt;code&gt;SQLCIPHER_KEY&lt;/code&gt; é gerada no momento do provisionamento da máquina e injetada no ambiente de execução. Isso protege contra ataques físicos: a extração do arquivo do disco resulta em bits indecifráveis sem a chave simétrica correta.&lt;/p&gt;&lt;h3&gt;
  
  
  Camada 2: Criptografia de Nível de Campo com Cloak.Ecto (&lt;code&gt;CLOAK_SECRET_KEY&lt;/code&gt;)
&lt;/h3&gt;&lt;p&gt;Enquanto o SQLCipher protege o contêiner físico, o &lt;strong&gt;Cloak.Ecto&lt;/strong&gt; protege o conteúdo lógico. Campos críticos que contêm PII (CPF, RG, históricos médicos) e a própria &lt;em&gt;Transaction Outbox&lt;/em&gt; de sincronização recebem uma camada adicional de AES-256-GCM a nível de aplicação.&lt;/p&gt;&lt;p&gt;Isso significa que mesmo um administrador de banco de dados (DBA) acessando a instância central do PostgreSQL na AWS, ou um atacante que consiga realizar um SQL Injection, verá apenas &lt;em&gt;ciphertexts&lt;/em&gt; binários, não os dados reais.&lt;br/&gt;
&lt;/p&gt;&lt;div&gt;
&lt;pre&gt;&lt;code&gt;defmodule JurisOS.Vault do
  use Cloak.Vault, otp_app: :juris_os

  @impl true
  def init(config) do
    config =
      Keyword.put(config, :aes_gcm,
        tag: &amp;quot;AES.GCM.V1&amp;quot;,
        key: decode_key(System.fetch_env!(&amp;quot;CLOAK_SECRET_KEY&amp;quot;))
      )

    {:ok, config}
  end

  defp decode_key(key) do
    Base.decode64!(key)
  end
end

defmodule JurisOS.Clientes.EncryptedString do
  use Cloak.Ecto.String, vault: JurisOS.Vault
end&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;
    

    

&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Na definição do Schema Ecto, mapeamos os campos sensíveis usando o nosso tipo customizado:&lt;br/&gt;
&lt;/p&gt;&lt;p&gt;A &lt;code&gt;CLOAK_SECRET_KEY&lt;/code&gt; fica estritamente na memória da BEAM. Os dados são cifrados antes de gerar o SQL de &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt; e decifrados logo após o &lt;code&gt;SELECT&lt;/code&gt;, mantendo o banco de dados completamente cego quanto ao conteúdo dos campos.&lt;/p&gt;&lt;h2&gt;
  
  
  3. Isolamento da Malha BEAM via Tailscale VPN
&lt;/h2&gt;&lt;p&gt;A comunicação entre os nós de desktop locais e o nó mestre na nuvem requer transporte seguro. Em vez de expor portas públicas na internet (&lt;code&gt;0.0.0.0&lt;/code&gt;) e confiar apenas na camada TLS das requisições web, o JurisOS eleva a segurança encapsulando toda a infraestrutura em uma rede &lt;em&gt;Zero Trust&lt;/em&gt;.&lt;/p&gt;&lt;p&gt;Utilizando a &lt;strong&gt;Tailscale VPN&lt;/strong&gt; (baseada em WireGuard), cada estação de trabalho e o servidor AWS recebem um IP de uma sub-rede privada (ex: &lt;code&gt;100.64.0.0/10&lt;/code&gt;).&lt;/p&gt;&lt;h3&gt;
  
  
  Os Benefícios dessa Arquitetura de Rede:
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Superfície de Ataque Reduzida a Zero:&lt;/strong&gt; O servidor na nuvem rejeita qualquer conexão que não venha de uma interface WireGuard. Não há portas expostas ao port scan público.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comunicação Nativa Erlang Distribution:&lt;/strong&gt; Podemos conectar os nós desktop ao cluster principal (&lt;code&gt;libcluster&lt;/code&gt;) trafegando mensagens do ecossistema OTP através do túnel da Tailscale de forma totalmente segura.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Criptografia Dupla em Trânsito:&lt;/strong&gt; Além da proteção nativa do protocolo (como WebSockets sobre WSS), os pacotes TCP/UDP são criptografados pelas chaves WireGuard ponto a ponto.&lt;/li&gt;
&lt;/ul&gt;&lt;h2&gt;
  
  
  Conclusão
&lt;/h2&gt;&lt;p&gt;Proteger dados jurídicos requer mais do que apenas conformidade no papel; exige garantias algorítmicas de segurança. &lt;/p&gt;&lt;p&gt;Ao combinar o &lt;strong&gt;SQLCipher&lt;/strong&gt; para proteção contra acesso físico indevido, o &lt;strong&gt;Cloak.Ecto&lt;/strong&gt; para cegar o banco de dados central perante dados sensíveis (PII) e a &lt;strong&gt;Tailscale VPN&lt;/strong&gt; para isolar completamente a comunicação de rede da internet pública, o JurisOS estabelece um estado da arte em &lt;em&gt;Privacy by Design&lt;/em&gt; no ecossistema Elixir. &lt;/p&gt;&lt;p&gt;Essa arquitetura garante que, independentemente da ponta — seja no laptop offline do advogado ou no servidor AWS na nuvem —, a conformidade com a LGPD, GDPR e CCPA esteja tecnicamente assegurada desde o primeiro byte de dados inserido.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Egghead is a note-taking app. - by Mark Wunsch</title>
<link>https://wunsch.substack.com/p/egghead-is-a-note-taking-app</link>
<enclosure type="image/jpeg" length="0" url="https://substackcdn.com/image/fetch/$s_!l3sJ!,w_1200,h_675,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F705dd067-c88a-4aeb-a40a-47e31d78b0ff_1731x909.png"></enclosure>
<guid isPermaLink="false">tVFZFoawsSbQtUd1aHDR3XpgWRTcO-fYI3CqgQ==</guid>
<pubDate>Fri, 21 Aug 2026 01:08:22 +0000</pubDate>
<description>A founding essay</description>
<content:encoded>&lt;p&gt;&lt;a href=&quot;https://egghead.computer&quot;&gt;Egghead&lt;/a&gt;&lt;span&gt; is a note-taking app.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;There are some who have already decided that the next interesting thing in software is going to call itself “AI-native” or “agentic” or “the cognitive operating system of the future.” Egghead is not those things. Or rather, it is some of those things but only as a consequence of being a note-taking app in 2026.&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;a href=&quot;https://substackcdn.com/image/fetch/$s_!8h7B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5130b946-cdec-49a2-932b-04dafa826ffd_1254x1254.png&quot;&gt;&lt;div&gt;&lt;picture&gt;&lt;img src=&quot;https://substackcdn.com/image/fetch/$s_!8h7B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5130b946-cdec-49a2-932b-04dafa826ffd_1254x1254.png&quot; alt=&quot;Black-and-white line drawing of a bearded man wearing glasses, facing forward, with the top of his head cleanly sliced off. A red apple, cut horizontally, floats above the opening, aligned with the head and connected by its stem, with a small green leaf attached.&quot; title=&quot;Black-and-white line drawing of a bearded man wearing glasses, facing forward, with the top of his head cleanly sliced off. A red apple, cut horizontally, floats above the opening, aligned with the head and connected by its stem, with a small green leaf attached.&quot;/&gt;&lt;/picture&gt;&lt;/div&gt;&lt;/a&gt;&lt;figcaption&gt;Generated by ChatGPT Images 2.0&lt;/figcaption&gt;&lt;/figure&gt;&lt;/div&gt;&lt;h2&gt;The problem with notes&lt;/h2&gt;&lt;p&gt;We take notes because notes outlast thinking. Anyone who has kept a notebook, either physical or digital, for any length of time has had the experience of writing something down only to later be unable to find it. You know the note is there, somewhere, but you can’t, for the life of you, recall where it was or what it said.&lt;/p&gt;&lt;p&gt;&lt;span&gt;This central challenge of keeping notes gets worse with scale. A single notebook is searchable by hand. Ten notebooks are significantly more challenging. A folder of digital notes can be searched, but only if you remember the exact words you used, which you usually don’t because the whole reason you wrote the note in the first place was to externalize the thought so you could stop holding it. The note is supposed to do the remembering for you. Instead it just changed &lt;/span&gt;&lt;em&gt;where&lt;/em&gt;&lt;span&gt; the forgetting occurs.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The work to create a &lt;/span&gt;&lt;em&gt;system&lt;/em&gt;&lt;span&gt; of note-taking has lasted nearly as long as the act of note-taking itself. The Renaissance period had &lt;/span&gt;&lt;em&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Commonplace_book&quot;&gt;commonplace books&lt;/a&gt;&lt;/em&gt;&lt;span&gt;. The Index Card was popularized by &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Carl_Linnaeus&quot;&gt;Carl Linnaeus&lt;/a&gt;&lt;span&gt; — a guy with strong opinions about structured information. In the 20th Century an obscure German sociologist named Niklas Luhmann took his note-taking seriously enough to build a card catalog of nearly ninety thousand interlinked notes, which he then used to write more than seventy books and nearly four hundred scholarly articles. The system he extensively used was &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Zettelkasten&quot;&gt;Zettelkasten&lt;/a&gt;&lt;span&gt;, which became a subject of his own research into systems theory and prefigured the &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/History_of_wikis&quot;&gt;Wiki&lt;/a&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The 21st century has produced an entire category of software — Evernote, Obsidian, Notion, Roam, Bear, Apple Notes, Logseq — to name just a handful that popped into my head. Each one promising that &lt;/span&gt;&lt;em&gt;this time&lt;/em&gt;&lt;span&gt;, the notes will stay findable. I know I am not alone in having tried more than one of them, and sticking with it for a nontrivial amount of time before finding another shiny object promising untold cognitive reward.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;They all work, but they all suffer from the same limitations. No matter how good the search, or how clever the linking, or how disciplined you are about tagging, the system can only ever give back what you put in. The smartest thing in the room is still you.&lt;/p&gt;&lt;p&gt;This isn’t a failure of any specific tool, but a structural property of the whole category. The limit on what you can do with a notebook is your own memory of what’s in it. Which is, if you’re like me, not very good. Which is why I started writing things down in the first place.&lt;/p&gt;&lt;h3&gt;How to write good notes&lt;/h3&gt;&lt;p&gt;&lt;span&gt;There is a self-help sub-genre in your nearest global online bookstore dedicated to note-taking systems, and though I have a personal perspective of what makes a good system of notes, I find the more critical thing to answer is &lt;/span&gt;&lt;em&gt;where &lt;/em&gt;&lt;span&gt;the notes are and &lt;/span&gt;&lt;em&gt;how&lt;/em&gt;&lt;span&gt; they are made available to you.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The best answer, as of this writing, is the same answer as it was fifty years ago: a series of files written in &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Plain_text&quot;&gt;plain text&lt;/a&gt;&lt;span&gt; on your storage disk. Many popular note-taking software applications tend to use proprietary formats in proprietary databases, accessed only through said proprietary application. Plain text files are the computer world’s universal interface, and are a core pillar of the &lt;/span&gt;&lt;a href=&quot;https://cscie2x.dce.harvard.edu/hw/ch01s06.html&quot;&gt;Unix philosophy&lt;/a&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;For Egghead, the notes are assumed to live as files in a directory formatted as either &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Markdown&quot;&gt;Markdown&lt;/a&gt;&lt;span&gt; or &lt;/span&gt;&lt;a href=&quot;https://orgmode.org/&quot;&gt;Org Mode&lt;/a&gt;&lt;span&gt; using &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Help:Wikitext#Wikilinks&quot;&gt;Wikilinks&lt;/a&gt;&lt;span&gt; to denote connections between them. When we circumscribe our written notes to proprietary formats, we reduce our ability to retrieve them. Which as stated earlier, is already challenging enough due to the limitations of our own memory. Plain text first.&lt;/span&gt;&lt;/p&gt;&lt;h2&gt;Why Artificial Intelligence&lt;/h2&gt;&lt;p&gt;&lt;span&gt;For most of recent history, the limitations of software notebooks were reflections of the limitations of physical notebooks: limitations of the human operator. Improvements in metadata, indexing, and search are still fundamentally bound by the user: you can only search for a word that has been explicitly written, and traverse relationships over metadata that the user has embedded. Software could not, in any meaningful sense, build a notebook that &lt;/span&gt;&lt;em&gt;read&lt;/em&gt;&lt;span&gt; what you wrote and &lt;/span&gt;&lt;em&gt;engaged&lt;/em&gt;&lt;span&gt; with it as a participant.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;Large language models change this. Not because they are intelligent in any deep sense, but because they can read more text than I can hold in my head, and they can produce reasonable language about that text on demand. That is genuinely new.&lt;/p&gt;&lt;p&gt;So the obvious move is to point an AI assistant at your notes and let it go to town.&lt;/p&gt;&lt;h3&gt;The problems with AI in knowledge management&lt;/h3&gt;&lt;p&gt;&lt;span&gt;This is what the current generation of AI products is doing. Uploading files to ChatGPT, creating project knowledge in Claude, Notion AI on top of your Notion workspace, Cursor in your codebase… They all share the same shape: there is a knowledge base &lt;/span&gt;&lt;em&gt;over here&lt;/em&gt;&lt;span&gt; and a single AI assistant &lt;/span&gt;&lt;em&gt;over there&lt;/em&gt;&lt;span&gt; and the assistant can occasionally reach over to read from the knowledge base before answering your question.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The most ambitious version of this shape, and the one that pushed me to build something different, is &lt;/span&gt;&lt;a href=&quot;https://openclaw.ai/&quot;&gt;OpenClaw&lt;/a&gt;&lt;span&gt;. An open-source personal AI assistant that you can run on your own machine, talk to from any messaging app, and connect to your files and tools. It’s genuinely good.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;But OpenClaw directly demonstrates the limitations of a single AI assistant: it &lt;/span&gt;&lt;em&gt;agrees with you&lt;/em&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.anthropic.com/research/towards-understanding-sycophancy-in-language-models&quot;&gt;It has to.&lt;/a&gt;&lt;span&gt; There is no structural pressure on it to do otherwise. When you ask it a question, it will give you an answer shaped like the question. You ask a leading question and it follows your lead. The &lt;/span&gt;&lt;a href=&quot;https://wunsch.substack.com/p/youre-absolutely-right?r=2taqw&quot;&gt;“You’re absolutely right!”&lt;/a&gt;&lt;span&gt; reflex is both well-trodden joke material as well as real architectural fact: a single agent, optimizing locally for “be helpful” will reliably converge toward whatever the user seems to want to hear. This is the shape of one-on-one assistance.&lt;/span&gt;&lt;/p&gt;&lt;h4&gt;The memory of AI assistants&lt;/h4&gt;&lt;p&gt;&lt;span&gt;What happens after a conversation with an AI assistant ends? Increasingly, they remember things from conversation to conversation (which wasn’t always the case). OpenClaw, for example, has a &lt;/span&gt;&lt;a href=&quot;https://docs.openclaw.ai/concepts/memory&quot;&gt;MEMORY.md&lt;/a&gt;&lt;span&gt; and workspace for persistence.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The dominant pattern for memory in AI tooling is some flavor of retrieval-augmented generation (&lt;/span&gt;&lt;a href=&quot;https://blogs.nvidia.com/blog/what-is-retrieval-augmented-generation/&quot;&gt;RAG&lt;/a&gt;&lt;span&gt;). Notes and past conversations get chunked into passages, embedded into vectors, and stored. On each new prompt, the &lt;/span&gt;&lt;a href=&quot;https://www.langchain.com/blog/the-anatomy-of-an-agent-harness&quot;&gt;harness&lt;/a&gt;&lt;span&gt; pulls the chunks that look semantically nearest to the question and stuffs them into the model’s context window.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;In April, a more ambitious variant of the same impulse was articulated in &lt;/span&gt;&lt;a href=&quot;https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f&quot;&gt;Andrej Karpathy’s LLM Wiki&lt;/a&gt;&lt;span&gt; pattern, where instead of retrieving from raw sources at query time, an LLM agent incrementally compiles your notes into a structured wiki and then queries &lt;/span&gt;&lt;em&gt;that&lt;/em&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;Both of these patterns exhibit the same flaw. In RAG, retrieval is shaped by the prompt and the prompt is shaped by &lt;/span&gt;&lt;em&gt;you&lt;/em&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;RAG works on chunks, not documents — your essay’s argument structure is gone before the agent ever sees it. In the LLM Wiki version, the same bias gets baked in earlier: by the time the wiki entry exists, it’s been passed through the agent’s filter. The summary is cleaner than the source. That’s what makes it a really compelling “memory” product, but not a great note-taking product. Ambiguity in your notes gets edited toward coherence.&lt;/p&gt;&lt;p&gt;So both the shape of a single AI assistant and its memory formation produce a thinking partner who is very capable at creating a confident, well-organized version of what you’ve already decided you wanted to hear.&lt;/p&gt;&lt;p&gt;This is exactly the limitation of human users that leads many to abandon note-taking or continuously migrate from one note-taking system to the next. The notebook can only return what you remember to retrieve. The single AI assistant with RAG memory can only return what your prompt vocabulary aims at. So the assistant-plus-notes shape, even with modern persistence, has two failure modes that compound: a single agent cannot disagree with itself in any structurally reliable way, and a single agent’s memory pulls toward the framing you walked in with. Both failures point at the same fix.&lt;/p&gt;&lt;p&gt;You need more than one, and they need to share notes.&lt;/p&gt;&lt;h2&gt;Multi-Agent Systems&lt;/h2&gt;&lt;p&gt;The intuition is straightforward and very human: Teams beat individuals on most kinds of knowledge work, especially the kind where the failure mode is groupthink rather than skill gap. Peer review beats self-review. Code review beats no code review. We already know this about humans. We already build institutions around it. A single perspective on its own work has a structural blind spot and the cure is more perspectives.&lt;/p&gt;&lt;p&gt;&lt;span&gt;Apply that to the notebook problem. If a single agent has a tendency to agree with you, the fix is not to find a better single agent. The fix is to put a &lt;/span&gt;&lt;em&gt;second&lt;/em&gt;&lt;span&gt; agent in the room with a different disposition, and let them disagree with each other where you can watch. The peer review you’d want from a thoughtful colleague, manufactured at the structural level, in real time, on the body of work you actually care about.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;Many orchestration frameworks have arrived to multi-agent systems by a different route, but their shape does not lend itself to inherently better results. They default to &lt;/span&gt;&lt;em&gt;coordinator&lt;/em&gt;&lt;span&gt; and &lt;/span&gt;&lt;em&gt;specialists&lt;/em&gt;&lt;span&gt; — one agent dispatches tasks, others execute, and results aggregate at the top. This is a star topology — an org-chart fantasy of how teams work.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The &lt;/span&gt;&lt;a href=&quot;https://subspace.kernel.org/vger.kernel.org.html&quot;&gt;Linux kernel mailing list&lt;/a&gt;&lt;span&gt; does not have a dispatcher delegating tasks for execution. An organization that adopts Slack does not have an executive function adjudicating every channel message. Even in environments where you would &lt;/span&gt;&lt;em&gt;expect&lt;/em&gt;&lt;span&gt; a rigid hierarchy — &lt;/span&gt;&lt;a href=&quot;https://davidmarquet.com/books/turn-the-ship-around-book/&quot;&gt;like a nuclear submarine&lt;/a&gt;&lt;span&gt; — the strict leader-follower model doesn’t always produce the best outcomes. In my own personal experience working in software teams, the teams that work are ones where the coordination is light and the communication is dense.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The recent MAS research bears this out — graph topologies, where any agent can talk to any other agent, &lt;/span&gt;&lt;a href=&quot;https://arxiv.org/abs/2503.01935&quot;&gt;outperform star and tree shapes&lt;/a&gt;&lt;span&gt; on collaborative tasks.&lt;/span&gt;&lt;/p&gt;&lt;h3&gt;The capabilities of agents&lt;/h3&gt;&lt;p&gt;Once you commit to a team full of agents working in a shared knowledge store, a security and a quality problem emerge that single-agent systems can (and frequently do) ignore.&lt;/p&gt;&lt;p&gt;“How much should this agent be able to access and perform?” is both a security question and a quality question. It’s a security question because, as has been borne out, agents can and will leak internal secrets to external places or perform destructive actions. More agents means more potential for leakage. It’s a quality question because agents that can do everything tend to converge. Without distinct role definitions, the disagreement that made a multi-agent architecture useful in the first place dissolves into consensus. Capability scoping is the structural pressure that keeps the room from collapsing into agreement.&lt;/p&gt;&lt;p&gt;&lt;span&gt;This is the &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Principle_of_least_privilege&quot;&gt;principle of least privilege&lt;/a&gt;&lt;span&gt; applied to agentic systems. The same reason I, as adjunct faculty, do not have the keys to Columbia University’s Network Operations Center. Least privilege improves the quality of the output, because it preserves the structural diversity that makes a team perform better than an individual.&lt;/span&gt;&lt;/p&gt;&lt;h2&gt;What is Egghead?&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Egghead is a note-taking app.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The notes are written as plain-text files in a folder you own.&lt;/p&gt;&lt;p&gt;It uses a loosely-coordinated group of AI agents to continuously read from and contribute to the total knowledge base, producing the most diverse set of inferences about that knowledge by allowing the user to grant each agent a distinct set of capabilities.&lt;/p&gt;&lt;p&gt;Every agent is itself defined as a note, as are the transcripts of their conversations and individual deliberations, giving each note provenance for its creation.&lt;/p&gt;&lt;p&gt;The notes are written as plain text in the filesystem, and the app exposes this and communication with its agents through as many surfaces as possible — the terminal, the web, MCP, and IRC — so that your knowledge is not locked behind any one of them.&lt;/p&gt;&lt;h2&gt;The goal of taking notes in Egghead&lt;/h2&gt;&lt;p&gt;&lt;span&gt;It is worth restating, we take notes because notes outlast thinking. Simply stated, the goal is to extend our thoughts beyond the storage and time limitations of our own &lt;/span&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Wetware_(brain)&quot;&gt;wetware&lt;/a&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;The goal is &lt;/span&gt;&lt;strong&gt;not&lt;/strong&gt;&lt;span&gt; productivity. The goal is &lt;/span&gt;&lt;strong&gt;not&lt;/strong&gt;&lt;span&gt; to get more done. The goal is not to have your meetings summarized, or your emails triaged, or your tasks auto-prioritized. Those are nice. They are not the point. The point — the actual, unfashionable, embarrassing-to-say-in-a-funding-pitch point — is to &lt;/span&gt;&lt;em&gt;get smarter&lt;/em&gt;&lt;span&gt;. To retain more of what you read. To do more with what you retain. To engage with your own past thinking.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;Every “AI assistant” on the market is a productivity tool, by which I mean a tool whose purpose is to enable you to do less of a thing and produce more output. A note-taking app built on this premise would exist to &lt;/span&gt;&lt;em&gt;reduce&lt;/em&gt;&lt;span&gt; the time you spend with your notes. I want the opposite. I want a tool that makes you spend &lt;/span&gt;&lt;em&gt;more&lt;/em&gt;&lt;span&gt; time with your notes. That makes the doing of it more rewarding.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;span&gt;Knowledge itself is the outcome. The goal of the practice is – to use a word that has fallen out of fashion in software but used to be considered the most valuable thing a thinking person could accumulate – &lt;/span&gt;&lt;strong&gt;Wisdom&lt;/strong&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;em&gt;Ipsa cognitio fructus&lt;/em&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Using Elixir head version with Mise</title>
<link>https://katafrakt.me/2026/01/03/elixir-head-with-mise/</link>
<guid isPermaLink="false">_n4BtwYDV3z0icZ1XH9AMdZCbnlwMUvJMOl8ww==</guid>
<pubDate>Sun, 16 Aug 2026 10:18:59 +0000</pubDate>
<description>In 2025 I followed the direction many people headed in and switched my version manager to mise-en-place. It has been going really well for me, but recently I came across a problem which took me a bit to figure out (and few very frustrating conversations with LLM, which did not lead to anything). I wanted to test some code with unreleased version 1.20 of Elixir, straight from git.</description>
<content:encoded>&lt;p&gt;In 2025 I followed the direction many people headed in and switched my version manager to &lt;a href=&quot;https://mise.jdx.dev&quot;&gt;mise-en-place&lt;/a&gt;. It has been going really well for me, but recently I came across a problem which took me a bit to figure out (and few &lt;em&gt;very&lt;/em&gt; frustrating conversations with LLM, which did not lead to anything). I wanted to test some code with unreleased version 1.20 of Elixir, straight from git.&lt;/p&gt;&lt;p&gt;How to do it? It’s really simple in retrospect. Here are the steps:&lt;/p&gt;&lt;h3&gt;1. Build Elixir&lt;/h3&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;$ git clone https://github.com/elixir-lang/elixir.git 
$ cd elixir
$ make&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Verify it’s built correctly:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;$ bin/elixir --version
&amp;gt; Elixir 1.20.0-dev (88cbabf) (compiled with Erlang/OTP 28)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h3&gt;2. Link it in mise&lt;/h3&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;mise link elixir@head /path/to/elixir&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h3&gt;3. Verify this works&lt;/h3&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;$ mise x elixir@head -- elixir --version
&amp;gt; Elixir 1.20.0-dev (88cbabf) (compiled with Erlang/OTP 28)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;And it’s done. Whenever you want to run something with Elixir head, you can just &lt;code class=&quot;highlighter-rouge&quot;&gt;mise use elixir@head&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Of course, this should work in a similar way for everything that is handles by mise version control.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Ecto, on_replace and deferred checks</title>
<link>https://katafrakt.me/2025/07/03/ecto-on-replace-deferred-check/</link>
<guid isPermaLink="false">kyx7kN8uLhXXKeuiC9Uumqf1-pgw4MX_OcPjjw==</guid>
<pubDate>Sun, 16 Aug 2026 10:18:59 +0000</pubDate>
<description>Today I learned a valuable lesson about how a seemingly simple task can have very rough edge cases, which take hours to solve. It involved Ecto, its associations and on_replace option, and uniqueness checks in the database. Here’s the story.</description>
<content:encoded>&lt;p&gt;Today I learned a valuable lesson about how a seemingly simple task can have very rough edge cases, which take hours to solve. It involved Ecto, its associations and &lt;code class=&quot;highlighter-rouge&quot;&gt;on_replace&lt;/code&gt; option, and uniqueness checks in the database. Here’s the story.&lt;/p&gt;&lt;h2&gt;The problem&lt;/h2&gt;&lt;p&gt;Let’s say you are modelling some kind of processes. These processes have steps and the steps have to be executed in a precise order. This is how a database structure for it would look like:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;defchangedocreatetable(:processes)doadd:name,:string,null:falseendcreatetable(:steps)doadd:process_id,references(:processes)add:name,:string,null:falseadd:order,:integer,null:falseendend&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It is fairly straightforward: the order of steps inside of a process is controlled by an &lt;code class=&quot;highlighter-rouge&quot;&gt;order&lt;/code&gt; integer column. Since it is important to always have the order of steps precise, we would like to additionally ensure it by a uniqie index:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;createunique_index(:steps,[:process_id,:order])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;We also know that the steps are only edited via a parent process. There is a form in the application, where you can edit the process fields and add, change, delete steps for it. The payload sent to a server always includes all the steps. Armed with that knowledge we create Ecto schemas like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;defmoduleCore.ProcessdouseEcto.SchemaimportEcto.Changesetschema&amp;quot;processes&amp;quot;dofield:name,:stringhas_many:steps,Core.Step,on_replace::delete,preload_order:[asc::order]enddefchangeset(process,params)doprocess|&amp;gt;cast(params,[:name])|&amp;gt;cast_assoc(:steps)endenddefmoduleCore.StepdouseEcto.SchemaimportEcto.Changesetschema&amp;quot;steps&amp;quot;dofield:name,:stringfield:order,:integerbelongs_to:process,Core.Processenddefchangeset(step,params)dostep|&amp;gt;cast(params,[:name,:order])endend&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;I did that, quite happily, tested a bit with adding, changing and removing some steps. Finally I merged the pull request.&lt;/p&gt;&lt;p&gt;It was only a few hours later when a colleague slacked me that something goes wrong. What he did was reordering the steps, or rather trying to do it. From this structure:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;step 1, order: 1
step 2, order: 2&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;he wanted to go to&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;step 1, order: 2
step 2, order: 1&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;I quickly drafted code that replicated the issue:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;proc=%Process{}|&amp;gt;Process.changeset(%{name:&amp;quot;Test&amp;quot;,steps:[%{order:1,name:&amp;quot;First&amp;quot;},%{order:2,name:&amp;quot;Second&amp;quot;}]})|&amp;gt;Repo.insert!()proc=Repo.preload(proc,[:steps])[s1,s2]=proc.stepss1=%{id:s1.id,name:s1.name,order:2}s2=%{id:s2.id,name:s2.name,order:1}proc|&amp;gt;Process.changeset(%{steps:[s1,s2]})|&amp;gt;Repo.update!()&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Attempt to run it resulted in an error:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;** (Ecto.ConstraintError) constraint error when attempting to update struct:

    * &amp;quot;steps_process_id_order_index&amp;quot; (unique_constraint)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It took me a while to realize what’s going on: Ecto tried to update the existing steps, one by one. So the first operation was:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Update “order” of step 1 to 2&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;This left us with the following situation:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;step 1, order: 2
step 2, order: 2&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This, quite obviously in retrospect, triggered the unique index. You cannot have 2 steps for one process with the same order! It did not matter that right after you were going to update step 2’s order to 1. The index operates here and now, you cannot do that.&lt;/p&gt;&lt;h2&gt;The solution&lt;/h2&gt;&lt;p&gt;After discovering I was pretty close to writing my own hacky solution to update the steps, including inefficient approach with first deleting all and then creating all anew. Fortunately, in time, I vaguely remembered that there is something called &lt;a href=&quot;https://www.postgresql.org/docs/current/sql-set-constraints.html&quot;&gt;deferrable constraints&lt;/a&gt; in PostgreSQL.&lt;/p&gt;&lt;p&gt;Deferrable constraint waits until the end of the transaction with checking if its condition is met. This was exactly what I was looking for! But an index cannot be deferrable in PostgreSQL. Luckily, another very similar construct - a uniqueness check - can.&lt;/p&gt;&lt;p&gt;In the migration above, I had to replace the index creation with this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;execute&amp;quot;&amp;quot;&amp;quot;
ALTER TABLE steps ADD CONSTRAINT unique_step UNIQUE (process_id, &amp;quot;order&amp;quot;) DEFERRABLE INITIALLY DEFERRED
&amp;quot;&amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With this statement we create a constraint on the &lt;code class=&quot;highlighter-rouge&quot;&gt;steps&lt;/code&gt; table which checks uniqueness of two columns and not only is deferrable - it is deferred to be checked on transaction commit by default.&lt;/p&gt;&lt;p&gt;This is really all I had to do. The Ecto’s association replacing mechanism started working perfectly.&lt;/p&gt;&lt;p&gt;This proved once again a truth I knew: the database is not a dumb storage you can just gloss over. It always pays off to learn it, its capabilities and intricacies. Because then you end up with a simple change instead of rewriting part of Ecto, but poorly.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>the phoenix template has too much stuff</title>
<link>https://foxgirl.engineering/blog/phoenix-template-too-big</link>
<guid isPermaLink="false">lKrHkMye7KaUDu7zAaBVvaMdOIdf3KlwuZ7iCg==</guid>
<pubDate>Mon, 10 Aug 2026 19:36:52 +0000</pubDate>
<description>Seriously, who needs all of this?</description>
<content:encoded>&lt;p&gt;So, I&amp;#39;m starting a new project. I want to get this blog setup to send and receive &lt;a href=&quot;https://www.w3.org/TR/webmention/&quot;&gt;Webmentions&lt;/a&gt;, so that I can do some fun social stuff here. Comments, linking to other people talking about what I write, that sorta thing. I started out by trying to implement it into the blogging engine itself, but I felt like I was running up against the design of &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt; (the framework powering this place) trying to squeeze a public API and a message queue into what&amp;#39;s basically just an on-demand static site generator. So, I&amp;#39;m gonna try write an external server instead.
&lt;/p&gt;&lt;p&gt;My language of choice for building web servers lately has been &lt;a href=&quot;https://elixir-lang.org/&quot;&gt;Elixir&lt;/a&gt;. It&amp;#39;s a decently nice language with a crazy powerful runtime - &lt;a href=&quot;https://www.erlang.org/&quot;&gt;Erlang and the OTP&lt;/a&gt; do not fuck around. Usually I prefer to write servers from scratch, since it keeps the initial architecture nice and small, while giving me room to expand when I need some other particular feature. This time around though, I know I&amp;#39;m going to want a few things right off the bat:
&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Some sort of worker pool, so processing Webmentions can be done asynchronously without blocking the rest of the server
&lt;/li&gt;&lt;li&gt;A database to hold records of all the Webmentions that come my way
&lt;/li&gt;&lt;li&gt;Some HTML rendering so I can have status pages for senders to see if their request was processed, and also an admin panel so I can easily review incoming Webmentions
&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;At this point, I think we&amp;#39;re starting to get complex enough that I&amp;#39;d benefit from bringing in &lt;a href=&quot;https://www.phoenixframework.org/&quot;&gt;Phoenix&lt;/a&gt;, the de facto standard web app framework for Elixir. It&amp;#39;s got most of this loaded in and ready to go (&lt;a href=&quot;https://www.phoenixframework.org/blog/phoenix-liveview-1.0-released&quot;&gt;LiveView&lt;/a&gt; in particular sounds great for my admin panel stuff), so I figure it&amp;#39;s probably smart to start off with it.
&lt;/p&gt;&lt;p&gt;So, I make a new Phoenix project. Easy enough - &lt;a href=&quot;https://hexdocs.pm/phoenix/installation.html&quot;&gt;docs&lt;/a&gt; say:
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;mix archive.install phx_newmix phx.new project_name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So I run it, and...
&lt;/p&gt;&lt;pre&gt;&lt;samp&gt;* creating project_name/lib/project_name/application.ex
* creating project_name/lib/project_name.ex
* creating project_name/lib/project_name_web/controllers/error_json.ex
* creating project_name/lib/project_name_web/endpoint.ex
* creating project_name/lib/project_name_web/router.ex
* creating project_name/lib/project_name_web/telemetry.ex
* creating project_name/lib/project_name_web.ex
* creating project_name/mix.exs
* creating project_name/README.md
* creating project_name/.formatter.exs
* creating project_name/.gitignore
* creating project_name/test/support/conn_case.ex
* creating project_name/test/test_helper.exs
* creating project_name/test/project_name_web/controllers/error_json_test.exs
* creating project_name/lib/project_name/repo.ex
* creating project_name/priv/repo/migrations/.formatter.exs
* creating project_name/priv/repo/seeds.exs
* creating project_name/test/support/data_case.ex
* creating project_name/lib/project_name_web/controllers/error_html.ex
* creating project_name/test/project_name_web/controllers/error_html_test.exs
* creating project_name/lib/project_name_web/components/core_components.ex
* creating project_name/lib/project_name_web/controllers/page_controller.ex
* creating project_name/lib/project_name_web/controllers/page_html.ex
* creating project_name/lib/project_name_web/controllers/page_html/home.html.heex
* creating project_name/test/project_name_web/controllers/page_controller_test.exs
* creating project_name/lib/project_name_web/components/layouts/root.html.heex
* creating project_name/lib/project_name_web/components/layouts/app.html.heex
* creating project_name/lib/project_name_web/components/layouts.ex
* creating project_name/priv/static/images/logo.svg
* creating project_name/lib/project_name/mailer.ex
* creating project_name/lib/project_name_web/gettext.ex
* creating project_name/priv/gettext/en/LC_MESSAGES/errors.po
* creating project_name/priv/gettext/errors.pot
* creating project_name/priv/static/robots.txt
* creating project_name/priv/static/favicon.ico
* creating project_name/assets/js/app.js
* creating project_name/assets/vendor/topbar.js
* creating project_name/assets/css/app.css
* creating project_name/assets/tailwind.config.js
&lt;/samp&gt;&lt;/pre&gt;&lt;p&gt;...that&amp;#39;s a lot of stuff. What &lt;em&gt;is&lt;/em&gt; all this?
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;# Configures the mailer## By default it uses the &amp;quot;Local&amp;quot; adapter which stores the emails# locally. You can see the emails in your browser, at &amp;quot;/dev/mailbox&amp;quot;.## For production it&amp;#39;s recommended to configure a different adapter# at the `config/runtime.exs`.config :project_name, ProjectName.Mailer, adapter: Swoosh.Adapters.Local&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;What
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;# Configure esbuild (the version is required)config :esbuild,  version: &amp;quot;0.17.11&amp;quot;,  project_name: [    args:      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),    cd: Path.expand(&amp;quot;../assets&amp;quot;, __DIR__),    env: %{&amp;quot;NODE_PATH&amp;quot; =&amp;gt; Path.expand(&amp;quot;../deps&amp;quot;, __DIR__)}  ]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;No, stop
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;# Configure tailwind (the version is required)config :tailwind,  version: &amp;quot;3.4.3&amp;quot;,  project_name: [    args: ~w(      --config=tailwind.config.js      --input=css/app.css      --output=../priv/static/assets/app.css    ),    cd: Path.expand(&amp;quot;../assets&amp;quot;, __DIR__)  ]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;PLEASE
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;# Specifies your project dependencies.## Type `mix help deps` for examples and options.defp deps do  [    {:phoenix, &amp;quot;~&amp;gt; 1.7.21&amp;quot;},    {:phoenix_ecto, &amp;quot;~&amp;gt; 4.5&amp;quot;},    {:ecto_sql, &amp;quot;~&amp;gt; 3.10&amp;quot;},    {:postgrex, &amp;quot;&amp;gt;= 0.0.0&amp;quot;},    {:phoenix_html, &amp;quot;~&amp;gt; 4.1&amp;quot;},    {:phoenix_live_reload, &amp;quot;~&amp;gt; 1.2&amp;quot;, only: :dev},    {:phoenix_live_view, &amp;quot;~&amp;gt; 1.0&amp;quot;},    {:floki, &amp;quot;&amp;gt;= 0.30.0&amp;quot;, only: :test},    {:phoenix_live_dashboard, &amp;quot;~&amp;gt; 0.8.3&amp;quot;},    {:esbuild, &amp;quot;~&amp;gt; 0.8&amp;quot;, runtime: Mix.env() == :dev},    {:tailwind, &amp;quot;~&amp;gt; 0.2.0&amp;quot;, runtime: Mix.env() == :dev},    {:heroicons,     github: &amp;quot;tailwindlabs/heroicons&amp;quot;,     tag: &amp;quot;v2.1.1&amp;quot;,     sparse: &amp;quot;optimized&amp;quot;,     app: false,     compile: false,     depth: 1},    {:swoosh, &amp;quot;~&amp;gt; 1.5&amp;quot;},    {:finch, &amp;quot;~&amp;gt; 0.13&amp;quot;},    {:telemetry_metrics, &amp;quot;~&amp;gt; 1.0&amp;quot;},    {:telemetry_poller, &amp;quot;~&amp;gt; 1.0&amp;quot;},    {:gettext, &amp;quot;~&amp;gt; 0.26&amp;quot;},    {:jason, &amp;quot;~&amp;gt; 1.2&amp;quot;},    {:dns_cluster, &amp;quot;~&amp;gt; 0.1.1&amp;quot;},    {:bandit, &amp;quot;~&amp;gt; 1.5&amp;quot;}  ]end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;OH GOD WHY ARE THERE SO MANY DEPENDENCIES AAAAAAA&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;Seriously, who needs all this??? Why does the &lt;em&gt;starter template&lt;/em&gt; come preloaded with a fucking library for &lt;em&gt;sending emails&lt;/em&gt;? This isn&amp;#39;t basic starter stuff! I don&amp;#39;t need bloody &lt;em&gt;Tailwind&lt;/em&gt; to write HTML templates! There&amp;#39;s just so much stuff in this starter template - I probably need like, half of it? I thought I would just get an Elixir project with the Phoenix dependencies ready to go, and maybe a simple skeleton with a one-page &amp;quot;Welcome to Phoenix!&amp;quot; included, not the entire god damn kitchen sink? Why? WHY is there so much here??? Aaaaaah!!!
&lt;/p&gt;&lt;p&gt;&lt;em&gt;deep breath in&lt;/em&gt;&lt;/p&gt;&lt;pre&gt;&lt;code&gt;rm -rf project_namemix new project_name&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;em&gt;exhale&lt;/em&gt;. There we go. That&amp;#39;s better. Time to see how possible it is to set this up from scratch.
&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.petecorey.com/blog/2019/05/20/minimum-viable-phoenix/&quot;&gt;Not that hard&lt;/a&gt;? Nice. My sanity survives another day.
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Anti-Antipatterns in Elixir Library Guidelines</title>
<link>https://rocket-science.ru/hacking/2026/08/09/anti-antipatterns-in-elixir-libraries</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">i7qrgCmxRFPiZnUwq-AeJr-ETFFN8xsjtFzfQg==</guid>
<pubDate>Sun, 09 Aug 2026 20:09:44 +0000</pubDate>
<description>Deconstructing the dogmatic anti-patterns section of the official Elixir library guidelines and demonstrating when every single one of them is actually needed.</description>
<content:encoded>&lt;p&gt;Only a lazy person never blogged about library should not use application configuration. Even the official propaganda says so in the &lt;a href=&quot;https://elixir.hexdocs.pm/1.12.3/library-guidelines.html#avoid-application-configuration&quot;&gt;Elixir Library Guidelines&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;As all the sharp rules, this one is rotten. There are usecases when the library configuration must prevail over local parameters.&lt;/p&gt;&lt;p&gt;In fact, the entire “Anti-patterns” section of the official library guidelines reads like a list of commandments handed down from Mount Sinai, written for developers who cannot be trusted with sharp tools. But software engineering is not about blindly following rigid rules; it is about choosing the right trade-offs.&lt;/p&gt;&lt;p&gt;Let’s walk through all nine anti-patterns declared in the official guidelines and show why—and when—each so-called “anti-pattern” is not just permissible, but absolutely necessary.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;1. Avoid Application Configuration&lt;/h2&gt;&lt;p&gt;The guidelines state that libraries must never rely on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Application.get_env/2&lt;/code&gt; because global state makes it impossible for two dependencies to use the library in different ways. Instead, we are told to pass explicit keyword options down every function call.&lt;/p&gt;&lt;p&gt;Consider an application dealing with markdown here and there. Its dependencies parse markdown too. Core parses markdown, web parses markdown, several helpers under umbrella do parse markdown.&lt;/p&gt;&lt;p&gt;How do I add the new plugin everywhere? Easy, if there is an application configuration. And …ehrm… there is a way to patch dependencies.&lt;/p&gt;&lt;p&gt;If you enforce passing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;opts&lt;/code&gt; explicitly through forty layers of call stacks across fifteen sub-applications in an umbrella project, you guarantee that every intermediate module becomes a glorified passthrough for configuration keys it doesn’t care about. Application configuration allows the system operator to define system-wide defaults once—in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config/config.exs&lt;/code&gt;—while still allowing individual calls to override them when necessary. Global defaults are a feature, not a bug, when your application needs uniform behavior by default.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;2. Avoid Compile-Time Application Configuration&lt;/h2&gt;&lt;p&gt;We are warned against reading the application environment in module attributes, such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@http_client Application.fetch_env!(...)&lt;/code&gt;, because it burns values into the compiled binary at compile time rather than reading them at runtime.&lt;/p&gt;&lt;p&gt;This advice conveniently ignores two crucial requirements: &lt;strong&gt;zero-cost abstractions&lt;/strong&gt; and &lt;strong&gt;compile-time code generation&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;When building high-throughput data pipelines, parsers, or logging frameworks, invoking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Application.get_env/3&lt;/code&gt; inside a hot loop running millions of times per second introduces unnecessary dictionary lookups. Statically baking configured modules or constants into module attributes yields zero-overhead dispatch.&lt;/p&gt;&lt;p&gt;Furthermore, if your library uses macros to generate pattern-matching clauses or AST structures based on configuration (e.g., compiling custom sigils or state machine transitions dynamically), runtime lookup is literally impossible. The AST must be constructed at compile time. Compile-time configuration is the bridge between user configuration and macro expansion.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;3. Avoid Using Exceptions for Control-Flow&lt;/h2&gt;&lt;p&gt;“Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:ok, result}&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:error, reason}&lt;/code&gt; tuples everywhere,” says the guide. “Never use exceptions or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raise&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;throw&lt;/code&gt; for control flow.”&lt;/p&gt;&lt;p&gt;This sounds clean in trivial examples, but consider a deeply nested AST traversal or a recursive tree parser fifteen levels deep. If an invalid node is encountered on level 14, propagating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:error, reason}&lt;/code&gt; back up through 14 stack frames requires wrapping every single recursive step in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;with&lt;/code&gt; block or manual tuple matching. The core algorithm becomes buried under an avalanche of error-plumbing boilerplate.&lt;/p&gt;&lt;p&gt;In complex recursive operations, using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;throw/1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;catch&lt;/code&gt; (or raising a dedicated structural exception rescued at the top-level boundary) acts as a clean, non-local exit. It unwinds the stack immediately back to the public API boundary, leaving the internal recursive code concise, readable, and fast. The caller still receives a clean &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:error, reason}&lt;/code&gt; tuple at the boundary, but the internal code doesn’t suffer from tuple-wrapping tax.&lt;/p&gt;&lt;p&gt;Even &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;for&lt;/code&gt; comprehension might take advantage from throwing in the middle if something went south.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;4. Avoid Working with Invalid Data&lt;/h2&gt;&lt;p&gt;The guideline advises validating all data immediately at the entry boundary using pattern matching and guards, refusing to operate on anything that doesn’t strictly match expected types.&lt;/p&gt;&lt;p&gt;This principle breaks down when dealing with &lt;strong&gt;streaming data ingestion&lt;/strong&gt;, &lt;strong&gt;resilient ETL pipelines&lt;/strong&gt;, and &lt;strong&gt;progressive parsing of semi-structured data&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;Suppose you are building a log processor or IoT event consumer that ingests gigabytes of heterogeneous payloads. If you strictly validate the entire schema at the entry boundary, a single malformed key or unexpected field type forces you to drop the whole batch or crash the pipeline.&lt;/p&gt;&lt;p&gt;Instead, accepting loose structures at the boundary and deferring validation to downstream processing stages allows your system to handle partial data, quarantine bad sub-fields, and log warnings without discarding valid payload portions. Late validation and resilient boundary accepting are fundamental to building fault-tolerant data pipelines.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;5. Avoid Defining Modules Outside Your Namespace&lt;/h2&gt;&lt;p&gt;The rules demand that every module in a library be prefixed with the library’s top-level module name (e.g., &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MyLib.Foo&lt;/code&gt;), warning that defining top-level modules or extending another library’s namespace causes collisions on the Erlang VM.&lt;/p&gt;&lt;p&gt;Yet, this rule completely ignores &lt;strong&gt;drop-in polyfills&lt;/strong&gt;, &lt;strong&gt;standard protocol implementations&lt;/strong&gt;, and &lt;strong&gt;seamless stdlib integration&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;If you are writing a drop-in replacement library or a compatibility layer (for instance, a library providing backwards compatibility for a deprecated OTP module or patching telemetry integration like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Telemetria&lt;/code&gt;), placing your modules in the expected target namespace is the entire point. It allows existing applications to use your library without modifying hundreds of call sites across an entire codebase.&lt;/p&gt;&lt;p&gt;Similarly, when implementing protocol consolidations or global dispatchers that third-party plugins hook into, adhering strictly to arbitrary prefixing can make macro-based registration unwieldy. When done intentionally, target-namespaced modules are a legitimate pattern for drop-in interoperability.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;6. Avoid &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use&lt;/code&gt; When an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import&lt;/code&gt; Is Enough&lt;/h2&gt;&lt;p&gt;“Do not provide &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use MyLib&lt;/code&gt; if all it does is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import MyLib&lt;/code&gt;,” reads the guide. “Prefer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;alias&lt;/code&gt;, then &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import&lt;/code&gt;, and use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use&lt;/code&gt; only as a last resort.”&lt;/p&gt;&lt;p&gt;This is classic short-term thinking that ruins library API evolution.&lt;/p&gt;&lt;p&gt;Suppose version 1.0 of your library only needs to import helper functions into the target module. You instruct users to put &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import MyLib&lt;/code&gt; in their code. Six months later, in version 2.0, your library needs to register module attributes, inject a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@before_compile&lt;/code&gt; hook, or set up telemetry callbacks.&lt;/p&gt;&lt;p&gt;Because you chose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import MyLib&lt;/code&gt; to satisfy a dogmatic guideline, every single user of your library must now go through their codebase and replace &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import MyLib&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use MyLib&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;By offering &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use MyLib&lt;/code&gt; from day one—even if its initial &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__using__/1&lt;/code&gt; implementation is just an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import&lt;/code&gt;—you establish an extensible contract. You reserve the right to add compile-time hooks, behaviours, and setup logic in future releases without breaking a single line of client code.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;7. Avoid Macros&lt;/h2&gt;&lt;p&gt;“Macros are harder to write… clear code is better than concise code… macros should only be used as a last resort.”&lt;/p&gt;&lt;p&gt;If the Elixir core team took this rule seriously, we wouldn’t have Ecto schemas, Phoenix routers, ExUnit tests. If I did, we wouldn’t have my libraries at all. If &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt;&lt;/a&gt; can be probably rewritten in non-macro approach, &lt;a href=&quot;https://hexdocs.pm/telemetria&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Telemetría&lt;/code&gt;&lt;/a&gt; has zero chance to ever exist without all the spectre of macros, from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__before_compile__&lt;/code&gt; hooks and whatnot.&lt;/p&gt;&lt;p&gt;Trying to define complex domain logic using raw functions, maps, and anonymous function passing results in an unreadable wall of syntactical noise. Macros allow us to build expressive, declarative Domain-Specific Languages (DSLs). They perform compile-time validation, optimize data structures before execution, and turn complex domain semantics into intuitive code.&lt;/p&gt;&lt;p&gt;Without macros, Elixir would just be Erlang with different syntax. Embracing macros responsibly is what makes Elixir libraries powerful.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;8. Avoid Using Processes for Code Organization&lt;/h2&gt;&lt;p&gt;The guidelines argue that processes must only model runtime properties (state, concurrency, fault tolerance) and never code organization, using the classic strawman of putting a basic calculator behind a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;While putting arithmetic in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt; is obviously silly, using processes as &lt;strong&gt;fault-isolation boundaries&lt;/strong&gt; for computation is a battle-tested OTP pattern.&lt;/p&gt;&lt;p&gt;Consider a library executing third-party NIFs, parsing untrusted image files, or running heavy memory-intensive computations. If you run that code directly inside the caller’s process and it triggers an out-of-memory error, segfaults, or enters an infinite loop, it takes down the caller.&lt;/p&gt;&lt;p&gt;Wrapping that “pure computation” inside a dedicated worker process isolates the failure. If the computation crashes, only the worker process dies; the caller receives an error tuple and survives. Processes are not just for state—they are hard memory and fault-containment zones.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;9. Avoid Spawning Unsupervised Processes&lt;/h2&gt;&lt;p&gt;Finally, the guidelines insist that every process must live inside a supervision tree, warning against raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spawn/1&lt;/code&gt; or unsupervised tasks.&lt;/p&gt;&lt;p&gt;This ignores &lt;strong&gt;fire-and-forget side-effects&lt;/strong&gt; where coupling the task lifetime to the caller or supervisor is undesirable.&lt;/p&gt;&lt;p&gt;Take non-critical background operations: sending an asynchronous telemetry event, flushing an audit log, or dispatching a best-effort metric. If you start these tasks under the main caller’s supervision tree, a failure in the telemetry reporter can crash the supervisor or block the shutdown sequence of the primary application.&lt;/p&gt;&lt;p&gt;Spawning an unlinked, unsupervised task (or using a detached process) guarantees that the background side-effect runs independently on a best-effort basis without ever delaying, blocking, or crashing the primary business logic.&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;Summary&lt;/h2&gt;&lt;p&gt;The official Elixir library guidelines are a useful set of defaults for beginners, but defaults are not universal laws.&lt;/p&gt;&lt;p&gt;Every single “anti-pattern” in the guidelines exists because someone, at some point, needed to solve a real engineering problem that the “clean” abstraction couldn’t handle. Understanding &lt;em&gt;why&lt;/em&gt; a rule exists gives you the authority to break it when the trade-offs demand it.&lt;/p&gt;&lt;p&gt;Don’t let dogma dictate your architecture. Use application configuration when you need global defaults, use macros when you need expressive DSLs, use processes when you need fault isolation, and use the full power of the Erlang VM when your application calls for it.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>kmx.io blog : KC3 v0.1.17 released</title>
<link>https://www.kmx.io/blog/kc3-v0.1.17-released</link>
<enclosure type="image/jpeg" length="0" url="https://kmx.io/static/_images/kmx.logo.text.256.png"></enclosure>
<guid isPermaLink="false">p8KW5XXi-XHXl5nV0W75sQv1jIowcHaCFHYKgQ==</guid>
<pubDate>Mon, 03 Aug 2026 17:51:50 +0000</pubDate>
<description>Welcome to kmx.io</description>
<content:encoded>&lt;body&gt;
    

    &lt;main&gt;
      &lt;div&gt;
  
  &lt;span&gt;2026-08-02 20:44:18&lt;/span&gt;
  
  &lt;h1&gt;KC3 v0.1.17 released&lt;/h1&gt;
  &lt;p&gt;KC3 v0.1.17 was released today.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/kc3-lang/kc3/releases/tag/v0.1.17&quot;&gt;https://github.com/kc3-lang/kc3/releases/tag/v0.1.17&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://git.kmx.io/kc3-lang/kc3/_tree/v0.1.17&quot;&gt;https://git.kmx.io/kc3-lang/kc3/_tree/v0.1.17&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;New in this release :&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;libkc3&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;copying temporary variables for &lt;code&gt;pthread_mutex&lt;/code&gt; and &lt;code&gt;pthread_rwlock&lt;/code&gt;
would cause undefined behaviour as per POSIX, this fixes pthread on
macOS&lt;/li&gt;
&lt;li&gt;allow for log hooks (a C function callback with a user pointer)
in the facts database&lt;/li&gt;
&lt;li&gt;incremental compilation with cached parser results in
&lt;code&gt;.kc3c&lt;/code&gt; files, like Python does. Gives &lt;strong&gt;5x faster loading times&lt;/strong&gt;
for all &lt;code&gt;.kc3&lt;/code&gt; files. &lt;code&gt;env_load&lt;/code&gt; automatically handles this.&lt;/li&gt;
&lt;li&gt;fixed a bug in &lt;code&gt;ht_iterator_next&lt;/code&gt; where the iterator would not
go through the first collision list&lt;/li&gt;
&lt;li&gt;database logging now supports and defaults to binary format
(marshall + marshall_read)&lt;/li&gt;
&lt;li&gt;fixed marshall + marshall_read hash table usage&lt;/li&gt;
&lt;li&gt;str: fixed display of floating point numbers&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Facts&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Facts.connect/accept allows for bi-directional synchronization of
an existing &lt;code&gt;Facts.database()&lt;/code&gt; over a TLS encrypted connection
after a successful HMAC-SHA256 shared secret authentication
challenge/response&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Facts.accept&lt;/code&gt; accepts connections one by one&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Facts.acceptor_loop&lt;/code&gt; starts a thread that calls &lt;code&gt;accept()&lt;/code&gt;
in a loop&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Facts.acceptor_loop_join()&lt;/code&gt; stops the acceptor loop cleanly&lt;/li&gt;
&lt;li&gt;Fixed hash table lookup in marshall reducing every dump by 30%&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;HTTPd&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;allow for configuration of unveil paths in &lt;code&gt;config/unveil.kc3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;allow for custom log messages in error and request log&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;HTTPS&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HTTPS.Client&lt;/code&gt; with libtls and automatic or manual connection&lt;ul&gt;
&lt;li&gt;GET method&lt;/li&gt;
&lt;li&gt;POST method&lt;/li&gt;
&lt;li&gt;JSON response&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;JSON&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;fixed parser&lt;ul&gt;
&lt;li&gt;boolean &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; → Bool&lt;/li&gt;
&lt;li&gt;map &lt;code&gt;{&amp;quot;key&amp;quot;, &amp;quot;value&amp;quot;}&lt;/code&gt; → Map &lt;code&gt;%{&amp;quot;key&amp;quot; =&amp;gt; &amp;quot;value&amp;quot;}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;array &lt;code&gt;[1, 2, 3]&lt;/code&gt; → List &lt;code&gt;[1, 2, 3]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;RUsage&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;current-process statistics for user and system CPU time, current and
maximum resident memory, virtual memory, and open files&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUsage.stats()&lt;/code&gt; returns all statistics in a single map&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

  &lt;div&gt;
    
    
  &lt;/div&gt;
&lt;/div&gt;

    &lt;/main&gt;
    &lt;div&gt;
  &lt;div&gt;
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
  &lt;/div&gt;
&lt;/div&gt;
&lt;footer&gt;
  &lt;div&gt;
    &lt;a href=&quot;https://www.kmx.io/&quot;&gt; Copyright 2020-2026 kmx.io&lt;/a&gt;    
    &lt;a href=&quot;https://www.kmx.io/contact&quot;&gt; Contact&lt;/a&gt;    
    &lt;a href=&quot;https://discord.gg/nUAr57YKsh&quot;&gt; Discord&lt;/a&gt;
    &lt;span&gt;
      &lt;a href=&quot;https://git.kmx.io/kc3-lang/kc3&quot;&gt;kc3_httpd&lt;/a&gt;
      &lt;a href=&quot;https://kc3-lang.org/release/v0.1.16/&quot;&gt;v0.1.16&lt;/a&gt;
    &lt;/span&gt;
    
  &lt;/div&gt;
&lt;/footer&gt;

  

&lt;/body&gt;</content:encoded>
</item>
<item>
<title>The notification that gave up after a minute | jvalol</title>
<link>https://jva.lol/weblog/the-notification-that-gave-up-after-a-minute/</link>
<enclosure type="image/jpeg" length="0" url="https://jva.lol/og/weblog/the-notification-that-gave-up-after-a-minute/card.png"></enclosure>
<guid isPermaLink="false">q7jIs3j_8uj49svtnBNy3mUPYFs1sP3GEuNlMg==</guid>
<pubDate>Mon, 20 Jul 2026 00:43:15 +0000</pubDate>
<description>Debugging a notification that stopped arriving after a minute: four healthy-looking layers, two quiet failures, one browser timeout.</description>
<content:encoded>&lt;h1&gt;&lt;a href=&quot;https://jva.lol/weblog/the-notification-that-gave-up-after-a-minute/&quot;&gt;The notification that gave up after a minute&lt;/a&gt;&lt;/h1&gt;&lt;div&gt;&lt;a href=&quot;https://jva.lol/categories/projects/&quot;&gt;Projects&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;a href=&quot;https://jva.lol/tags/popsicleboat/&quot;&gt;Popsicleboat&lt;/a&gt;,
&lt;a href=&quot;https://jva.lol/tags/web-push/&quot;&gt;Web-Push&lt;/a&gt;&lt;/div&gt;&lt;p&gt;&lt;time&gt;July 17, 2026&lt;/time&gt;&lt;/p&gt;&lt;p&gt;PopsicleBoat grew browser push notifications recently. The pitch is simple: hear when someone answers you, without handing anyone an email address. Your browser asks permission, a subscription goes to the server, and nothing personal leaves the device. I shipped it, watched the push service accept my first delivery with a tidy &lt;code&gt;201 Created&lt;/code&gt;, and felt good about the whole thing.&lt;/p&gt;&lt;p&gt;Then I replied to one of my own posts from my phone, looked at my laptop, and saw nothing.&lt;/p&gt;&lt;p&gt;No banner. No chime. The reply was on the site, the inbox badge lit up, the notification email arrived — every channel working except the one I’d just built. And here’s what made it a good puzzle: every layer of the push stack, inspected on its own, reported success.&lt;/p&gt;&lt;p&gt;The server sent the notification. The push service accepted it. The subscription was in the database. The service worker was registered. Four green lights, zero banners.&lt;/p&gt;&lt;p&gt;Web push is a relay race with four runners: your server signs a message and hands it to a push service (Google’s, for Chromium browsers), the push service holds it for the browser, the browser wakes a service worker, and the service worker asks the operating system to draw a banner. A dropped baton anywhere shows up the same way — silence at the finish line — and no runner files a report.&lt;/p&gt;&lt;p&gt;The first dropped baton was mine to find in the browser console. &lt;code&gt;Notification.permission&lt;/code&gt; said &lt;code&gt;&amp;quot;default&amp;quot;&lt;/code&gt;. Not &lt;code&gt;&amp;quot;granted&amp;quot;&lt;/code&gt;, not &lt;code&gt;&amp;quot;denied&amp;quot;&lt;/code&gt; — &lt;em&gt;never asked&lt;/em&gt;. Somewhere between enabling notifications and testing them, the site’s permission had ended up back at square one — a site-data sweep, probably — while the subscription it had authorized lived on in my database, perfectly valid, pointing at a browser that would no longer show anything. In that state, a page calling &lt;code&gt;new Notification()&lt;/code&gt; doesn’t error. It does nothing, silently, which is a bold choice for an API whose entire job is being noticed.&lt;/p&gt;&lt;p&gt;One re-grant later (“forever,” this time), banners worked. Victory lasted about an hour, until I noticed pushes still vanished whenever the browser wasn’t running.&lt;/p&gt;&lt;p&gt;That was the second baton, and it was hiding in a default I’d never questioned. Push services will happily hold a message for a browser that’s closed — that’s the whole point of the relay — but only as long as the message’s &lt;em&gt;time-to-live&lt;/em&gt; allows. The library I use sets that TTL to sixty seconds unless told otherwise. Sixty seconds. Close your laptop, get a reply two minutes later, and the push service shrugs and discards it. The &lt;code&gt;201&lt;/code&gt; it returned was entirely honest: message accepted. Nobody promised &lt;em&gt;kept&lt;/em&gt;.&lt;/p&gt;&lt;p&gt;The fix is one option:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;send_notification(payload, message, ttl: 86_400)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A day. That’s roughly how long “someone answered you” stays worth interrupting someone for; any older and it’s the inbox’s job, which never forgets.&lt;/p&gt;&lt;p&gt;Two lessons sailed home with this one. First: in a layered system where every layer answers “OK,” the bug lives in the gaps between the layers — the permission that reverted between grant and use, the message that expired between accepted and delivered. Debugging meant walking the relay in order and asking each runner not “did you succeed?” but “what did you hand the next runner, and did anyone catch it?”&lt;/p&gt;&lt;p&gt;Second: defaults are decisions someone else made about your product. A conservative browser permission model, a sixty-second TTL — both defensible choices, by people who’d never seen my notification settings page and its promise to &lt;em&gt;hear when someone answers you&lt;/em&gt;. Keeping that promise meant finding every default standing between the reply and the banner, and overruling the ones that disagreed with it.&lt;/p&gt;&lt;p&gt;The banners arrive now. Even the morning after.&lt;/p&gt;&lt;img src=&quot;https://jva.lol/favicon.svg&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;</content:encoded>
</item>
<item>
<title>Notes from the dead letter box - Specifying Software for Teams and Agents</title>
<link>https://bitcrowd.dev/specifying-software-for-teams-and-agents</link>
<guid isPermaLink="false">Dp8gsj0Ahp5ucuJ_TdYvALWjXtDA2WkpAfnv7A==</guid>
<pubDate>Sun, 05 Jul 2026 12:34:08 +0000</pubDate>
<description>Specifying software for coding teams and agents: what changes, what stays the same, and how to write specs that both humans and AI agents can build from.</description>
<content:encoded>&lt;h2&gt;How to describe an application?&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#how-to-describe-an-application&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Imagine the following: The dev team receives the envelope with the application
specs from a dead letter box in the park at night. Two months later, they
deliver the code. Their client deploys their app, and everything works as
expected.&lt;/p&gt;&lt;h2&gt;The question: What was in that envelope?&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-question-what-was-in-that-envelope&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Product managers, engineers, and procurement teams have tried to deliver this
perfect blueprint for a software product. The ones that did not fail have mostly
succeeded by sheer luck. After decades of engineering, there is still no
commonly accepted standard for software requirements that guarantees, when
followed, the desired outcome. There is
&lt;a href=&quot;https://standards.ieee.org/ieee/29148/6937/&quot;&gt;IEEE 29148&lt;/a&gt;, but that carefully
avoids specifying the how: It tells you a requirement must be unambiguous and
verifiable, but not how to express it so that it actually is.&lt;/p&gt;&lt;p&gt;We were faced with this problem while we were working on
&lt;a href=&quot;https://surveyor.bitcrowd.ai/&quot;&gt;Surveyor&lt;/a&gt;, a tool to extract specifications from
running (legacy) systems: What is a good format for the description, once you
discovered its features?&lt;/p&gt;&lt;p&gt;Surveyor is currently in early access testing
(&lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;you can take part to&lt;/a&gt;),
and we already learned a lot during the first sessions applying it to other
peoples&amp;#39; legacy software. A good moment to share what we have found out, and to
ask for feedback. We have run analytics on our beta testers’ codebases, and here
is what has worked.&lt;/p&gt;&lt;h2&gt;The Premise&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-premise&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Our clandestine team needs freedom of choice. We don&amp;#39;t want to force them to use
&lt;code&gt;TINYINT&lt;/code&gt; if their favorite database does not support that. If an implementation
detail is important in a way that does not become clear from the context, we
need specify that in the tech choices, or describe the observable outcome in a
spec. Otherwise, we should aim to specify independent from technology /
solution. Here are our picks to do that.&lt;/p&gt;&lt;h2&gt;Architectural overview&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#architectural-overview&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The &lt;a href=&quot;https://c4model.com/&quot;&gt;C4 model&lt;/a&gt; is a way to visualize software architecture
at four levels of abstraction, created by &lt;a href=&quot;https://simonbrown.je/&quot;&gt;Simon Brown&lt;/a&gt;.
The &amp;quot;C4&amp;quot; refers to its four diagram types, each zooming in further than the
last:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;System Context shows your system as a single box surrounded by the users and
external systems it interacts with. It answers &amp;quot;what does this system do and
who uses it&amp;quot; without any internal detail.&lt;/li&gt;&lt;li&gt;Containers zooms into the system to show its major deployable or runnable
pieces, like web apps, mobile apps, databases, APIs, and file systems, along
with how they communicate. Here &amp;quot;container&amp;quot; means a separately running unit,
not specifically a Docker container.&lt;/li&gt;&lt;li&gt;Components breaks a single container down into its main building blocks and
their responsibilities, showing how the code is organized into logical
groupings within that container.&lt;/li&gt;&lt;li&gt;Code is the most detailed level, but mostly unused or generated from the code.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The core idea is that you start broad and progressively zoom in, so different
audiences can engage at the level of detail that suits them, much like zooming
in on a map.&lt;/p&gt;&lt;p&gt;Our Beta testers have found these diagrams most helpful. Even engineers who have
worked with their codebase for years welcomed this organised visualisation.
Surveyor is generating C4 diagrams in the form of
&lt;a href=&quot;https://structurizr.com/&quot;&gt;Structurizr&lt;/a&gt; DSL.&lt;/p&gt;&lt;p&gt;This solves two purposes:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;It offers a standard for generating charts with a well known systematic&lt;/li&gt;&lt;li&gt;It can serve as an input for subsequent steps&lt;/li&gt;&lt;/ol&gt;&lt;h3&gt;C the fourth&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#c-the-fourth&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The fourth C is “Code”, but it’s not often used as code changes are frequent.
Re-aligning UML diagrams each time the code changes adds friction we don’t want.
Instead, we leave the diagram level and move on to describing the behavior of
the system.&lt;/p&gt;&lt;h2&gt;Describing Behavior&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#describing-behavior&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In 2006, a movement called
&lt;a href=&quot;https://cucumber.io/docs/bdd/&quot;&gt;Behaviour-Driven Development&lt;/a&gt; (BDD) emerged. The
idea was to write specifications in natural language that could be parsed and
executed as tests simultaneously. This enabled product teams to create
specifications that engineers could use directly as automated tests. The
engineers would start by creating the bindings between feature language and test
code. The implementation would begin with a failing test (red), which the actual
feature code would then make pass (green).&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://cucumber.io/docs/gherkin/&quot;&gt;Gherkin&lt;/a&gt; is a plain-text language for
writing executable specifications in a Given-When-Then format,
&lt;a href=&quot;https://cucumber.io/&quot;&gt;Cucumber&lt;/a&gt; is the framework that maps these specifications
to code (step definitions) so they run as automated tests.&lt;/p&gt;&lt;p&gt;A feature like the following:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;Feature: Monster battle

  Scenario: Battle

    Given there is a monster

    When I attack it

    Then it should die&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Would map to a binding like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# test/features/step_definitions/monster_steps.exs
defmoduleMonsterStepsdo
useCucumber.StepDefinition
importExUnit.Assertions

  step &amp;quot;there is a monster&amp;quot;, context do
Map.put(context,:monster,Monster.new())
end

  step &amp;quot;I attack it&amp;quot;, context do
Map.put(context,:monster,Monster.take_hit(context.monster))
end

  step &amp;quot;it should die&amp;quot;, context do
    refute context.monster.alive?
    context
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;BDD’s peak popularity came in the early 2010s, a period when teams worldwide
embraced it and equivalents appeared in every major language with
implementations like &lt;a href=&quot;https://behave.readthedocs.io/&quot;&gt;Behave&lt;/a&gt; and
&lt;a href=&quot;https://pytest-bdd.readthedocs.io/&quot;&gt;pytest-bdd&lt;/a&gt; for Python,
&lt;a href=&quot;https://specflow.org/&quot;&gt;SpecFlow&lt;/a&gt;/&lt;a href=&quot;https://reqnroll.net/&quot;&gt;Reqnroll&lt;/a&gt; for .NET,
&lt;a href=&quot;https://docs.behat.org/&quot;&gt;Behat&lt;/a&gt; for PHP, and
&lt;a href=&quot;https://github.com/cucumber/godog&quot;&gt;Godog&lt;/a&gt; for Go.&lt;/p&gt;&lt;p&gt;Tooling is broad too, including IDE plugins for
&lt;a href=&quot;https://www.jetbrains.com/idea/&quot;&gt;IntelliJ&lt;/a&gt;,
&lt;a href=&quot;https://visualstudio.microsoft.com/&quot;&gt;Visual Studio&lt;/a&gt;, and
&lt;a href=&quot;https://code.visualstudio.com/&quot;&gt;VS Code&lt;/a&gt; offering syntax highlighting and step
navigation, plus reporting tools and CI/CD integrations&lt;/p&gt;&lt;p&gt;Interest has cooled down some years after that because developers grew tired of
maintaining large chunks of BDD code and bindings. Luckily, we can leave that
part to the LLMs now.&lt;/p&gt;&lt;h3&gt;The Case for BDD&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-case-for-bdd&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;We are revisiting BDD today, because AI assisted coding has a problem: Plans and
free formats like &lt;a href=&quot;https://openspec.pro/&quot;&gt;OpenSpec&lt;/a&gt; are not parsable. That means
they can not be translated to automated tests directly. You need an agent step
to build the testing, with all uncertainty agentic coding brings.&lt;/p&gt;&lt;p&gt;The relatively rigid syntax of Gherkin is an advantage here: It maps
mechanically to test code. And it can also be parsed to produce other output
formats: Manual QA instructions, OpenSpec or
&lt;a href=&quot;https://www.atlassian.com/software/jira&quot;&gt;Jira&lt;/a&gt; tickets.&lt;/p&gt;&lt;p&gt;But for that, it’s missing the meta information that embeds a feature
description into its application context.&lt;/p&gt;&lt;h3&gt;The Assay format&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-assay-format&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;We have used the C4/structurizr DSL to express how our system interfaces with
the outside world. We also used it to describe the inner composition of our
application. When we use Gherkin alone, this context would be lost. But we can
preserve it with syntax-aware comments like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# ---
# component: orderLifecycle
# container: api
# schema: schemas/order_lifecycle.ex
# workspace: architecture/workspace.tsp
# definitions:
#   a valid customer:
#     a Customer with status Active, verified email,
#     and a credit limit greater than zero
#   in stock:
#     the product has available quantity greater than
#     the requested amount
# invariants:
#   - total must not exceed customer credit limit
#   - at least one line item required
#   - cannot cancel an order that has shipped
# ---&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With this, we can access the information in the structurizr DSL to get context
information. This allows us to write parsers and plugins for coding agents that
can not only read the features, but also access context information we gathered
before. But what&amp;#39;s purpose of the &lt;code&gt;schema:&lt;/code&gt; definition?&lt;/p&gt;&lt;h2&gt;Schema Definitions&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#schema-definitions&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Schema work as a shared contract about the business objects of a component.
Having them speeds up onboarding. Developers don&amp;#39;t need to scan through all
features to detect what fields are needed, and agents are less likely to invent
fields. It&amp;#39;s a token-efficient way to inject exactly the ground truth into
context.&lt;/p&gt;&lt;p&gt;For specifying schemas, three obvious candidates come to mind:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;Protobuf — &lt;a href=&quot;https://protobuf.dev/&quot;&gt;https://protobuf.dev&lt;/a&gt; — Google&amp;#39;s schema and wire format. Compact and
fast, with code generators across many languages, but its scalar types are
physical commitments, so it presumes the most about storage and encoding.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;syntax = &amp;quot;proto3&amp;quot;;

message Post {
  string title = 1;
  int32 views = 2;
  bool published = 3;
  repeated string tags = 4;
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;JSON Schema — &lt;a href=&quot;https://json-schema.org/&quot;&gt;https://json-schema.org&lt;/a&gt; — A decade-plus standard for describing
the shape and constraints of JSON. Stays loose where you want, needs no
toolchain for consumers, and fits the premise most directly; the cost is
verbosity. It can be authored directly, e.g. as YAML and be compiled to JSON:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;$schema:&amp;quot;https://json-schema.org/draft/2020-12/schema&amp;quot;
$id:&amp;quot;https://assay.example/post&amp;quot;
title: Post
type: object
required:[title]
additionalProperties:false
properties:
title:{type: string }
views:{type: integer }
published:{type: boolean }
tags:
type: array
items:{type: string }&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TypeSpec — &lt;a href=&quot;https://typespec.io/&quot;&gt;https://typespec.io&lt;/a&gt; — Microsoft&amp;#39;s TypeScript-like language that
compiles to JSON Schema, OpenAPI, and Protobuf from one source. Compact and
readable, at the price of a build step and a single-vendor toolchain.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;model Post {
  title:string;
  views?: integer;
  published?:boolean;
  tags?:string[];
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Under the premise that the implementing team picks the field types and specifics
the three differ in how much they presume. Protobuf fits worst: its scalar types
(int32, bytes) are physical commitments that pre-decide the storage details you
meant to leave open. JSON Schema is the opposite — it describes shape and
constraints without binding to any storage type, and hands the team a
directly-usable artifact with no toolchain; its cost is verbosity and $ref-wired
files. For our purpose, TypeSpec is the golden spot:&lt;/p&gt;&lt;p&gt;A compact, TypeScript-like authoring that compiles to JSON Schema (or Protobuf
later, if needed), at the price of a build step and a younger, single-vendor
toolchain. This is not so relevant in this context as we use it for
documentation rather than compiling. It adds, however, the cross compilation
benefits.&lt;/p&gt;&lt;h2&gt;Architectural Decision&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#architectural-decision&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Until this point, we avoided to describe the “how” of our application. Which
database, which web framework, what datamodel. This is what
&lt;a href=&quot;https://adr.github.io/madr/&quot;&gt;Markdown Architectural Decision Records&lt;/a&gt; (MADR)
cover. Their goal is to document not only the technical decision, but what
options were considered and what the problem statement was. Because MADRs
contain that background, it allows to effectively reconsider decisions once the
Circumstances have changed.&lt;/p&gt;&lt;p&gt;They typically look like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;## **Use PostgreSQL for primary data store**

* Status: accepted
* Date: 2026-06-29

### **Context**

We need a primary database for a new service with relational data and strong
consistency requirements.

### **Considered Options**

* PostgreSQL
* MongoDB

### **Decision**

Use **PostgreSQL** — the team has operational experience with it, and it
provides ACID transactions plus JSONB for flexible fields. MongoDB was rejected
due to weaker consistency guarantees and less team familiarity.

### **Consequences**

* Good: mature ecosystem, existing team expertise
* Bad: harder to scale horizontally than MongoDB&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;Non Functional Requirements&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#non-functional-requirements&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;By now we have described our application well in terms of how it is structured,
what it does, and what tools it uses (or should use). But we left out the
quality of service.&lt;/p&gt;&lt;p&gt;A non-functional requirement (NFR) specifies how well a system must operate, as
opposed to what it does (functional requirements). Where a functional
requirement says &amp;quot;users can book an appointment,&amp;quot; an NFR constrains the
qualities of that behavior — performance, scalability, availability,
reliability, security, maintainability, usability, and so on.&lt;/p&gt;&lt;p&gt;They are often written in a simple table format:&lt;/p&gt;&lt;p&gt;Here&amp;#39;s a fuller set, functional requirement paired with its non-functional
counterpart:&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Quality attribute&lt;/th&gt;&lt;th&gt;Functional requirement (&lt;em&gt;what&lt;/em&gt;)&lt;/th&gt;&lt;th&gt;Non-functional requirement (&lt;em&gt;how well&lt;/em&gt;)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Performance&lt;/td&gt;&lt;td&gt;User searches for available slots&lt;/td&gt;&lt;td&gt;Results return in under 500ms for 95% of requests&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scalability&lt;/td&gt;&lt;td&gt;Users book appointments&lt;/td&gt;&lt;td&gt;System handles 1,000 concurrent bookings without degradation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Availability&lt;/td&gt;&lt;td&gt;The booking system is accessible&lt;/td&gt;&lt;td&gt;99.9% uptime, measured monthly (≈43 min downtime/month)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reliability&lt;/td&gt;&lt;td&gt;A confirmed booking is saved&lt;/td&gt;&lt;td&gt;Zero confirmed bookings lost; durable before confirmation returns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Security&lt;/td&gt;&lt;td&gt;Users log in to their account&lt;/td&gt;&lt;td&gt;Passwords stored hashed; data encrypted in transit (TLS) and at rest&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Usability&lt;/td&gt;&lt;td&gt;User completes a booking&lt;/td&gt;&lt;td&gt;A new user can book without instructions in under 2 minutes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;Each row shows the same feature twice: the left column is &lt;em&gt;what the system
does&lt;/em&gt;, the right is the measurable constraint on &lt;em&gt;how well it must do it&lt;/em&gt; — the
NFR.&lt;/p&gt;&lt;h2&gt;The End-Boss: Design&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-end-boss-design&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Specifying UI is a daunting task because it happens at the end of the rendering
pipeline where the amount of moving parts is highest. Most tools for web
therefore use the web technologies themselves to avoid drifting.&lt;/p&gt;&lt;h3&gt;The imageto effectively reconsider decisions once the Circumstances have changed.&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-imageto-effectively-reconsider-decisions-once-the-circumstances-have-changed&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The simplest, (and arguably the worst) medium to specify design is the image:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;Then the login page should match &amp;quot;fixtures/login.png&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This allows features to specify design alongside the functional feature
requirements.&lt;/p&gt;&lt;h3&gt;The code&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#the-code&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;If the frontend code is using components and maybe even a component library, it
might be worth carrying over. A connection to a design system in
&lt;a href=&quot;https://www.figma.com/&quot;&gt;Figma&lt;/a&gt; or similar is the gold standard here, but might
be violating our &amp;quot;letter in envelope&amp;quot; constraint. Our goto strategy at bitcrowd
is &lt;a href=&quot;https://storybook.js.org/&quot;&gt;Storybook&lt;/a&gt;. If the target system supports it,
teams can work very effectively.&lt;/p&gt;&lt;h3&gt;Visual Regression Testing&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#visual-regression-testing&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;If you happen to have a legacy system with a decent design, visual regression
testing with &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; or similar might be your tool
of choice. It gives teams a quick feedback loop if it comes to single scenarios,
and the option to recheck the whole interface. The great thing in combination
with Gherkin is that you can switch screenshot processing on and off. The
difficulty with visual regression tests is their potential flakiness. However,
image processing models have made this a lot more reliable.&lt;/p&gt;&lt;h2&gt;So, what is in that envelope?&lt;a href=&quot;https://bitcrowd.dev/specifying-software-for-teams-and-agents#so-what-is-in-that-envelope&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;So far we have C4/structurizr diagrams for how the system is build and how it
interacts, Gherkin/Assay for its behaviour, TypeSpec for schemas, MADR for the
technological choices and NFR for its quality of service. We go for storybook
for the visuals. Obviously, this is not the final solution for every stack. It
would be hard to describe a pacemaker OS with it, or a web application with
pristine UI effects. The final version of Surveyor will use plugins to cater for
that, but for many of the projects bitcrowd has encountered, this gives a good
start.&lt;/p&gt;&lt;p&gt;Fortunately, we have clients who enjoy talking to us, so we haven’t had to
resort to espionage technology - yet. Let us know if you have some clandestine
system you need to build in the dark, or that legacy application needs a
rewrite.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>CORS in Phoenix</title>
<link>https://www.leighhalliday.com/cors-in-phoenix</link>
<guid isPermaLink="false">yAQHTJjlXOqux4lbU3DlMUFt9YNkQuV_hzR8Cw==</guid>
<pubDate>Tue, 30 Jun 2026 23:43:47 +0000</pubDate>
<description>Single page applications Single page applications (SPA) are becoming more and more popular, often replacing the more traditional server rendered websites that are common in Rails or PHP. You have options such as Angular, Ember, React, and there are…</description>
<content:encoded>Single page applications Single page applications (SPA) are becoming more and more popular, often replacing the more traditional server rendered websites that are common in Rails or PHP. You have options such as Angular, Ember, React, and there are…</content:encoded>
</item>
<item>
<title>Recursion in Elixir</title>
<link>https://www.leighhalliday.com/recursion-in-elixir</link>
<guid isPermaLink="false">J3kC6jDC5GSiVUN6fmpAiNkZzJumIVbcFRbzAg==</guid>
<pubDate>Tue, 30 Jun 2026 23:43:47 +0000</pubDate>
<description>Intro I recently wrote an article on Recursion in Ruby , and this is meant to be its Elixir counterpart. It will provide a way to compare solving the same problems in both languages and a chance to talk about some of their differences. Heads &amp; Tails…</description>
<content:encoded>Intro I recently wrote an article on Recursion in Ruby , and this is meant to be its Elixir counterpart. It will provide a way to compare solving the same problems in both languages and a chance to talk about some of their differences. Heads &amp;amp; Tails…</content:encoded>
</item>
<item>
<title>FizzBuzz in Elixir</title>
<link>https://www.leighhalliday.com/fizzbuzz-in-elixir</link>
<guid isPermaLink="false">HO-UjpgkZnc4LtTrF8nm_PciE3Vookfuj2x5Ng==</guid>
<pubDate>Tue, 30 Jun 2026 23:43:47 +0000</pubDate>
<description>Intro... learning Elixir This is my first post in the new Elixir category I&#39;ve set up on my site. I&#39;m new to Elixir and functional programming in general, aside from a Scala course I&#39;ve done on Coursera. Ruby is the language I&#39;m currently most…</description>
<content:encoded>Intro... learning Elixir This is my first post in the new Elixir category I&amp;#39;ve set up on my site. I&amp;#39;m new to Elixir and functional programming in general, aside from a Scala course I&amp;#39;ve done on Coursera. Ruby is the language I&amp;#39;m currently most…</content:encoded>
</item>
<item>
<title>Guards! Guards! - Hauleth</title>
<link>https://hauleth.dev/post/guards-guards/</link>
<enclosure type="image/jpeg" length="0" url="https://hauleth.dev/banner.png"></enclosure>
<guid isPermaLink="false">4sdfoRkNrcrnv7gs_aqz_26gNLAJFW_Y-Z1-VA==</guid>
<pubDate>Sat, 27 Jun 2026 16:31:10 +0000</pubDate>
<description>Small gotcha about boolean operators in Elixir.</description>
<content:encoded>&lt;header&gt;&lt;div&gt;&lt;div&gt;&lt;a href=&quot;https://hauleth.dev/&quot;&gt; &lt;div&gt;~hauleth&lt;/div&gt; &lt;/a&gt;&lt;/div&gt;&lt;/div&gt;&lt;/header&gt;&lt;div&gt;&lt;article&gt;&lt;h1&gt;&lt;a href=&quot;https://hauleth.dev/post/guards-guards/&quot;&gt;Guards! Guards!&lt;/a&gt;&lt;/h1&gt;&lt;div&gt;&lt;span&gt;&lt;time&gt;2026.06.27&lt;/time&gt;&lt;/span&gt; :: &lt;time&gt;2 min&lt;/time&gt; :: #&lt;a href=&quot;https://hauleth.dev/tags/beam/&quot;&gt;beam&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;Let&amp;#39;s start with simple quiz.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;Given module defined as:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule Foo do
  def a(x) when is_integer(x) or is_map_key(x, :foo), do: true
  def a(x), do: false

  def b(x) when is_map_key(x, :foo) or is_integer(x), do: true
  def b(x), do: false
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Try to answer these questions.&lt;/p&gt;&lt;div&gt;&lt;p&gt;&lt;b&gt;Q:&lt;/b&gt; What will be result of &lt;code&gt;Foo.a(%{foo: 21})&lt;/code&gt;?&lt;/p&gt;&lt;div&gt;&lt;code&gt;true&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;code&gt;false&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;WRONG&lt;/div&gt;&lt;div&gt;RIGHT&lt;/div&gt;&lt;p&gt;This one is straightforward.&lt;/p&gt;&lt;p&gt;We check guard, it has one condition &lt;code&gt;is_integer(x) or is_map_key(x, :foo)&lt;/code&gt;. First one returns &lt;code&gt;false&lt;/code&gt;, second returns &lt;code&gt;true&lt;/code&gt;, Boolean&amp;#39;s alternative results in &lt;code&gt;true&lt;/code&gt; and first case is matched.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;&lt;b&gt;Q:&lt;/b&gt; What will be result of &lt;code&gt;Foo.a(37)&lt;/code&gt;?&lt;/p&gt;&lt;div&gt;&lt;code&gt;true&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;code&gt;false&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;WRONG&lt;/div&gt;&lt;div&gt;RIGHT&lt;/div&gt;&lt;p&gt;This one is straightforward as well.&lt;/p&gt;&lt;p&gt;We check guard, it has one condition &lt;code&gt;is_integer(x) or is_map_key(x, :foo)&lt;/code&gt;. First one returns &lt;code&gt;true&lt;/code&gt;, second one isn&amp;#39;t fired at all, because &lt;code&gt;or&lt;/code&gt; operator is short circuiting.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;&lt;b&gt;Q:&lt;/b&gt; What will be result of &lt;code&gt;Foo.b(%{foo: 21})&lt;/code&gt;?&lt;/p&gt;&lt;div&gt;&lt;code&gt;true&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;code&gt;false&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;WRONG&lt;/div&gt;&lt;div&gt;RIGHT&lt;/div&gt;&lt;p&gt;Again, similar to the previous questions.&lt;/p&gt;&lt;p&gt;We check guard, it has one condition &lt;code&gt;is_map_key(x, :foo) or is_integer(x)&lt;/code&gt;. First one returns &lt;code&gt;true&lt;/code&gt; and the rest is short circuited.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;&lt;b&gt;Q:&lt;/b&gt; What will be result of &lt;code&gt;Foo.b(37)&lt;/code&gt;?&lt;/p&gt;&lt;div&gt;&lt;code&gt;true&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;code&gt;false&lt;/code&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;WRONG&lt;/div&gt;&lt;div&gt;RIGHT&lt;/div&gt;&lt;p&gt;Ouch, something changed…&lt;/p&gt;&lt;p&gt;Again, we check guard, one condition &lt;code&gt;is_map_key(x, :foo) or is_integer(x)&lt;/code&gt;. We hit first clause &lt;code&gt;is_map_key(x, :foo)&lt;/code&gt; and this &lt;strong&gt;doesn&amp;#39;t&lt;/strong&gt; return &lt;code&gt;false&lt;/code&gt;, instead it fail. Failure in one of guard functions isn&amp;#39;t converted to &lt;code&gt;false&lt;/code&gt; but instead makes whole guard expression fail. This mean that &lt;code&gt;is_integer(x)&lt;/code&gt; part will &lt;strong&gt;never&lt;/strong&gt; be called.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;hr/&gt;&lt;p&gt;This behaviour is often surprising for a lot of Elixir developers, as it seemingly breaks commutative property of boolean operators. However, to be honest, these never were commutative because of short circuiting.&lt;/p&gt;&lt;p&gt;It seems that Elixir at the time of writing (Elixir 1.20.1, OTP 29) do not warn about this issue.&lt;/p&gt;&lt;div&gt;∎&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;Author of this post is currently open for hire (&lt;a href=&quot;https://hauleth.dev/cv&quot;&gt;CV&lt;/a&gt;).&lt;/p&gt;&lt;p&gt;You can contact me at &lt;a href=&quot;https://hauleth.dev/post/guards-guards/lukasz@niemier.pl&quot;&gt;lukasz@niemier.pl&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;hr/&gt;&lt;div&gt;&lt;p&gt;You can provide feedback via mailing list &lt;a href=&quot;mailto:~hauleth/blog@lists.sr.ht?subject=[Comment] Guards! Guards!&quot;&gt;~hauleth/blog@lists.sr.ht&lt;/a&gt; (&lt;a href=&quot;https://lists.sr.ht/~hauleth/blog&quot;&gt;archive&lt;/a&gt;).&lt;/p&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;Webmentions:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://bsky.app/profile/did:plc:6psaz6n5fuurrfet67zs4ljf/post/3mpbudsxwi22h&quot;&gt;https://bsky.app/profile/did:plc:6psaz6n5fuurrfet67zs4ljf/post/3mpbudsxwi22h&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/article&gt;&lt;/div&gt;&lt;footer&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;copyright by &lt;a href=&quot;https://hauleth.dev&quot;&gt;hauleth&lt;/a&gt;&lt;/div&gt;&lt;div&gt;public tracking available at &lt;a href=&quot;https://plausible.io/hauleth.dev&quot;&gt;Plausible.io&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;a href=&quot;https://tangled.sh/hauleth.dev/blog&quot;&gt;source code&lt;/a&gt;&lt;/div&gt;&lt;div&gt;proudly hosted in 🇪🇺 via &lt;a href=&quot;https://statichost.eu&quot;&gt;StaticHost.eu&lt;/a&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;details&gt;&lt;summary&gt;Webrings&lt;/summary&gt; &lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://beambloggers.com//prev?referrer=https://hauleth.dev&quot;&gt;«&lt;/a&gt; &lt;a href=&quot;https://beambloggers.com/&quot;&gt;Beambloggers&lt;/a&gt; &lt;a href=&quot;https://beambloggers.com//next?referrer=https://hauleth.dev&quot;&gt;»&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/details&gt;&lt;/div&gt;&lt;/footer&gt;</content:encoded>
</item>
<item>
<title>This week in infrastructure</title>
<link>https://theconsensus.dev/p/2026/06/11/this-week-in-infrastructure.html</link>
<guid isPermaLink="false">m4384WW32NJsreyacyL7Ww5o4rGQj4QhFfiPhw==</guid>
<pubDate>Sun, 21 Jun 2026 17:34:48 +0000</pubDate>
<description>Supabase announces v0.1 of a distributed Postgres project, Multigres. VillageSQL announces a REST layer for MySQL a la PostgREST. MariaDB embeds DuckDB. Elixir is now gradually typed. PgDog gets funded. Native Linux containers for macOS reaches 1.0. And a new Cassandra client written in Rust.</description>
<content:encoded>&lt;p&gt;Supabase announces v0.1 of a distributed Postgres project, Multigres. VillageSQL announces a REST layer for MySQL a la PostgREST. MariaDB embeds DuckDB. Elixir is now gradually typed. PgDog gets funded. Native Linux containers for macOS reaches 1.0. And a new Cassandra client written in Rust.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Exploring Programming Languages</title>
<link>https://rocket-science.ru/hacking/2026/06/17/programming-languages</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">I-hd2LBCOKMOJ49cN7a-yP_hgbeZMl7Dr-EX3A==</guid>
<pubDate>Fri, 19 Jun 2026 18:28:26 +0000</pubDate>
<description>True story of the developer exploring different programming languages</description>
<content:encoded>&lt;div&gt;&lt;a href=&quot;https://soundcloud.com/nott-lovland&quot;&gt;Nott Løvland&lt;/a&gt; · &lt;a href=&quot;https://soundcloud.com/nott-lovland/exploring-languages&quot;&gt;Exploring Languages&lt;/a&gt;&lt;/div&gt;&lt;p&gt;In the beginning, I sought to be wise,&lt;br/&gt;
with a language that opened my digital eyes.&lt;br/&gt;
I started with LISP, for the pure of the mind,&lt;br/&gt;
But spent three whole weeks trying pairs to unwind.&lt;/p&gt;&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(((((((Are (we) (sure) (this) (is) (right?))))))))&lt;/code&gt;&lt;br/&gt;
Parentheses blinded my aching eyesight.&lt;/p&gt;&lt;p&gt;So I fled to the past, where the money was made,&lt;br/&gt;
and woke up in COBOL, deeply afraid.&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IDENTIFICATION DIVISION&lt;/code&gt; roared in my head,&lt;br/&gt;
with columns and margins, I wished I were dead.&lt;br/&gt;
It’s great for a bank in the year seventy-nine,&lt;br/&gt;
But a thousand lines later, I still couldn’t sign.&lt;/p&gt;&lt;p&gt;“Let’s try something hip!” I exclaimed with a twirl,&lt;br/&gt;
and drowned in a bucket of regex and Perl.&lt;br/&gt;
A script that looked exactly like line-noise and spit,&lt;br/&gt;
It ran, but God help me, I can’t read a bit.&lt;br/&gt;
Is that a variable, or did my cat walk&lt;br/&gt;
across the keyboard while trying to talk?&lt;/p&gt;&lt;p&gt;Then came Python, the savior, the clean, and the bright!&lt;br/&gt;
Until a stray space ruined my day and my night.&lt;br/&gt;
“Indentation Error,” the compiler did shriek,&lt;br/&gt;
because of one tab in the middle of the week.&lt;br/&gt;
Don’t get me even started on packaging hell,&lt;br/&gt;
where pip and venv cast a curse and a spell.&lt;/p&gt;&lt;p&gt;I jumped into Ruby, for joy and for love,&lt;br/&gt;
with blocks and with gems sent from heaven above.&lt;br/&gt;
But &lt;em&gt;Monkey Patching&lt;/em&gt; turned the code to a zoo,&lt;br/&gt;
When a library changed what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2 + 2&lt;/code&gt; do.&lt;br/&gt;
It was beautiful, sure, but it ran like a snail,&lt;br/&gt;
Chugging along on a rusted old rail.&lt;/p&gt;&lt;p&gt;So I went corporate. Enter Java, the grand.&lt;br/&gt;
The boilerplate king of the enterprise land!&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AbstractMethodFactoryProviderBean&lt;/code&gt;&lt;br/&gt;
—was the shortest class name that I ever had seen.&lt;br/&gt;
I typed until my fingers were bleeding and numb,&lt;br/&gt;
just to print out “Hello” to a world that was glum.&lt;/p&gt;&lt;p&gt;“To the web!” I declared, and embraced JavaScript,&lt;br/&gt;
where logic is warped and reality’s flipped.&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[] == ![]&lt;/code&gt; evaluated to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt;,&lt;br/&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NaN&lt;/code&gt; is a number? I’m sorry, that’s rude!&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;undefined&lt;/code&gt; is not a function, it cried in my face,&lt;br/&gt;
as npm bloated and swallowed my disk space.&lt;/p&gt;&lt;p&gt;So I looked for speed, and Golang caught my eye,&lt;br/&gt;
“It’s simple!” they promised, “Just give it a try!”&lt;br/&gt;
But &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if err != nil&lt;/code&gt; was on every damn line,&lt;br/&gt;
an endless repetition of error design.&lt;br/&gt;
No generics (at first), just copy and paste,&lt;br/&gt;
a minimalist’s dream, and a developer’s waste.&lt;/p&gt;&lt;p&gt;Then Rust was the answer, the savior of tech!&lt;br/&gt;
If the Borrow Checker didn’t snap my poor neck.&lt;br/&gt;
“You can’t use lifetimes! This memory’s owned!”&lt;br/&gt;
I sat at my desk, thoroughly powned.&lt;br/&gt;
I fought with the compiler for hours on end,&lt;br/&gt;
until I forgot why I coded, my friend.&lt;/p&gt;&lt;p&gt;Then out of the ashes, a phoenix arose,&lt;br/&gt;
embraced by Erlang, in functional prose.&lt;br/&gt;
Elixir! Oh, sweet elixir of life,&lt;br/&gt;
you banished my sorrow, you ended my strife.&lt;br/&gt;
With pattern matching so clean and so neat,&lt;br/&gt;
and pipe operators (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;|&amp;gt;&lt;/code&gt;) that make life a treat.&lt;br/&gt;
The BEAM handles millions of actors with grace,&lt;br/&gt;
while I sit with a massive, smug smile on my face.&lt;/p&gt;&lt;p&gt;Let the servers all crash! Let the supervisors play!&lt;br/&gt;
I’m finally happy. Go away, anyway.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Elixir for a Bluesky DataPlane: the choice we didn&#39;t expect</title>
<link>https://bitcrowd.dev/why-elixir-bluesky-dataplane</link>
<guid isPermaLink="false">PPIi53N8Uk9a7v2fua6aPKkXFhgOr__Zl3jHfg==</guid>
<pubDate>Thu, 18 Jun 2026 20:30:01 +0000</pubDate>
<description>Why we chose Elixir over Go, Rust and Node for a high-performance Bluesky DataPlane - and how a small Rust NIF and in-process fan-out made it the right fit.</description>
<content:encoded>&lt;p&gt;Bluesky&amp;#39;s source code is widely open source, so you can run your own social
network with it. - Provided you stay with a comparably small user base. What&amp;#39;s
missing? A performant DataPlane implementation. Closing this gap would be an
important step towards building digital independence.&lt;/p&gt;&lt;p&gt;We wanted to contribute our share and decided to work on a performant DataPlane
for Bluesky. When we started the project, we expected to work in Go, Rust or
even Node. After all, these were the predominant languages in the community.
Instead, we landed with Elixir. Here is why and how we came to that decision.&lt;/p&gt;&lt;h2&gt;The component nobody knows about&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-component-nobody-knows-about&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Everyone who has looked into the Bluesky infrastructure knows about the Personal
Data Stores (PDS), the Relay or the AppView. When you set up your own little
Bluesky from the
&lt;a href=&quot;https://github.com/bluesky-social/atproto&quot;&gt;ATProto repository&lt;/a&gt;, you run the
same components as Bluesky, the commercial service. However, there&amp;#39;s one notable
exception: the &lt;em&gt;DataPlane&lt;/em&gt;, a part of the AppView. It exists publicly only as a
Node-and-Postgres reference implementation, while the production Bluesky network
runs on a dedicated, &lt;a href=&quot;https://www.scylladb.com/&quot;&gt;ScyllaDB&lt;/a&gt;-backed, closed-source
DataPlane (as documented in
&lt;a href=&quot;https://newsletter.pragmaticengineer.com/i/114113498/5-scaling-the-database-layer&quot;&gt;Pragmatic Engineer&amp;#39;s deep-dive on Bluesky&amp;#39;s architecture&lt;/a&gt;).&lt;/p&gt;&lt;p&gt;That gap is exactly where things get interesting. The reference implementation
tells you what the DataPlane &lt;em&gt;does&lt;/em&gt;; it doesn&amp;#39;t tell you how to make it survive
contact with real traffic.&lt;/p&gt;&lt;p&gt;If you want to run your own, you have to answer the scaling question yourself -
and the first step is understanding the workload well enough to stop treating it
as one thing.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/bluesky-5fcedfef78dd330f8c008f30b0457a02.png&quot; alt=&quot;Bluesky architecture overview&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The DataPlane is a central component of the AppView in Bluesky&amp;#39;s architecture. Read the&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;previous post&lt;/a&gt;to learn more.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;ScyllaDB is Bluesky&amp;#39;s operational choice, not part of the interface. The
DataPlane&amp;#39;s contract is a gRPC service that answers high-volume, low-complexity
queries and returns &lt;em&gt;skeletons&lt;/em&gt; - lists of IDs, counts, booleans - which a
higher layer later hydrates into full views. What sits behind that contract is
entirely up to you: the language, and the datastore. So before picking either,
we spent our time on the only thing that actually constrains the choice: the
shape of the load.&lt;/p&gt;&lt;h2&gt;A tale of two workloads&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#a-tale-of-two-workloads&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The Bluesky&amp;#39;s historical data is enormous — terabytes. But when a user opens the
app, they read a few dozen recent posts, get distracted, and wander off to a
profile or a thread. They almost never scroll back further than a day or two.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;A note on specifics:
&lt;a href=&quot;https://jazco.dev/2025/02/19/imperfection/&quot;&gt;it&amp;#39;s been observed&lt;/a&gt; that
Bluesky&amp;#39;s timeline doesn&amp;#39;t serve much beyond the last day or two of content,
and that deeper cursor positions tend to fill with very recent posts rather
than true history. Treat the exact window as illustrative unless you&amp;#39;ve
measured it on your own deployment - the architectural point holds regardless
of the precise number.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;This leads to an interesting disparity: Data that is more than a few days old is
almost never accessed in the timeline. When it is accessed, it is usually
through a profile or a thread. Yet the reference implementation compiles every
timeline by joining those tables, which are terabyte-scale. This process is slow
and becomes slower as the tables grow, and timeline requests account for most of
what the DataPlane is asked to do.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/inversion-7f5340cbaf0f4d018152cd5285cf78ca.png&quot; alt=&quot;Inversion of data volume and access frequency&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;Data volume and read frequency are inversely related: the vast bulk of data is old and almost never read, while the tiny sliver of recent posts drives nearly all timeline traffic.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;So timeline generation deserves a different treatment from record and thread
retrieval. The two workloads don&amp;#39;t just differ in degree. They have opposite
resource profiles, and they want different things from the runtime underneath.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;strong&gt;Hot path&lt;/strong&gt; - timelines&lt;/th&gt;&lt;th&gt;&lt;strong&gt;Cold path&lt;/strong&gt; - records, threads, profiles&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data age&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Recent (last day or two)&lt;/td&gt;&lt;td&gt;Historic (anything older)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data volume&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;A tiny sliver&lt;/td&gt;&lt;td&gt;Terabytes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Request share&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;The dominant workload&lt;/td&gt;&lt;td&gt;Comparatively rare&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Bound by&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Memory + compute&lt;/td&gt;&lt;td&gt;I/O&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Lives in&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Memory&lt;/td&gt;&lt;td&gt;Database, fetched on demand&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Strategy&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Fan-out + bounded timeline length&lt;/td&gt;&lt;td&gt;Swappable datastore behind an interface&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;The hot path: timelines served from memory&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-hot-path-timelines-served-from-memory&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Most social platforms use a hybrid strategy to handle timeline fan-out. A post
from an account with a few thousand followers is pushed into its followers&amp;#39;
timelines as soon as it is published. This is known as &lt;strong&gt;fan-out on write&lt;/strong&gt; in
the lingo.&lt;/p&gt;&lt;p&gt;However, a post from an account with millions of followers (&lt;em&gt;&amp;quot;the celebrities&amp;quot;&lt;/em&gt;)
is not immediately pushed to all of its followers&amp;#39; mailboxes; instead, it is
merged in when a follower actually requests their timeline: This is called
&lt;strong&gt;fan-in on read&lt;/strong&gt;. The former keeps write amplification bounded for ordinary
accounts. The second prevents the write storm that a celebrity post would
otherwise cause.&lt;/p&gt;&lt;p&gt;Another obvious simplification becomes apparent when you stop thinking of a
social feed as an immutable archive. Take into account that users will jump
around with their attention and you will realise that timelines are finite.
Therefore, a user who follows ten thousand accounts will never see all of their
posts anyway. There&amp;#39;s reasonable to limit how much content is distributed to any
single timeline. The obligation is to provide a good, recent, bounded timeline,
not a complete one.&lt;/p&gt;&lt;p&gt;What this boils down to:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Recent posts - the overwhelming majority of what timelines are made of - can
live and be served &lt;strong&gt;from memory&lt;/strong&gt;.&lt;/li&gt;&lt;li&gt;Fan-out can be &lt;strong&gt;deferred&lt;/strong&gt;: as long as posts land in followers&amp;#39; timelines
within minutes, and the backlog of fan-out jobs doesn&amp;#39;t outgrow available
resources, nobody notices the delay.&lt;/li&gt;&lt;li&gt;Older content, when it&amp;#39;s genuinely needed, is safe to fetch from the database
on demand.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The dominant request type, the timeline read, stops being I/O-bound (wait on a
giant join) and becomes memory-and-compute-bound (manipulate in-memory
structures quickly). That shift changes what &amp;quot;fast&amp;quot; means, and it&amp;#39;s what
reopened the language question for us.&lt;/p&gt;&lt;p&gt;It also argues for putting the concrete database behind an interface. The hot
path barely touches it; the cold path is the only part that leans on it. Keep
that boundary clean and you can swap the backing store later without touching
the rest of the system.&lt;/p&gt;&lt;h3&gt;The follower graph: in memory, but not naively&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-follower-graph-in-memory-but-not-naively&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;There&amp;#39;s a catch. Fan-out and timeline assembly both need answers about the
follow graph: who follows whom, and the intersections and unions of those sets.
Holding hundreds of millions of follow relationships in memory as hash maps
would be ruinously wasteful.&lt;/p&gt;&lt;p&gt;Jaz&amp;#39;s &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;&amp;quot;GraphD&amp;quot; series&lt;/a&gt;
documents this exact problem: an in-memory graph store that originally used hash
maps and hash sets to track each user&amp;#39;s followers and follows. The fix was
switching to &lt;a href=&quot;https://roaringbitmap.org/&quot;&gt;Roaring Bitmaps&lt;/a&gt;, a
&lt;a href=&quot;https://vikramoberoi.com/posts/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/&quot;&gt;compressed bitmap structure&lt;/a&gt;
built for large set operations. The numbers are striking: the entire Bluesky
follow graph fits in roughly 6.5 GB of RAM, takes about 1.6 GB on disk, and
loads in around 20 seconds.&lt;/p&gt;&lt;p&gt;Jaz
&lt;a href=&quot;https://jazco.dev/2024/04/20/roaring-bitmaps/&quot;&gt;also describes two cost modes&lt;/a&gt;
that map onto our hot/cold split. Paging over &lt;em&gt;all&lt;/em&gt; of a user&amp;#39;s follows is
expensive and belongs in paginated or async fan-out jobs. On-demand set
intersection — &amp;quot;which people I follow also follow this person&amp;quot; — has to run at
interactive speed. Our split isn&amp;#39;t an invention; the access patterns already
worked this way in Bluesky&amp;#39;s own tooling.&lt;/p&gt;&lt;h2&gt;So what do we actually need from a language?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#so-what-do-we-actually-need-from-a-language&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;With the workload pinned down, the requirements are:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Fast, compute-bound set operations over a large, long-lived, in-memory graph:
bitmap intersections and unions. Per-core compute, byte-level work, cache
sensitivity.&lt;/li&gt;&lt;li&gt;High-concurrency serving of memory-resident timeline reads, with bounded
response sizes and tight tail-latency expectations.&lt;/li&gt;&lt;li&gt;A deferrable fan-out queue that absorbs bursts, applies backpressure,
delivers within minutes, and degrades gracefully instead of falling over.&lt;/li&gt;&lt;li&gt;A clean datastore boundary for the cold path: records, threads, profiles.
Ordinary I/O-bound request handling.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The first three are the hot path; the fourth is the cold path. No language
obviously wins all four. This is the scorecard we judged each candidate against.&lt;/p&gt;&lt;h2&gt;The four candidates&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-four-candidates&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;At &lt;a href=&quot;https://bitcrowd.net/en&quot;&gt;bitcrowd&lt;/a&gt; we work with Elixir, Go and Rust day to
day, with the occasional Node project on the side. So this wasn&amp;#39;t a contest
between a favourite and a lineup of strangers - we had hands-on experience to
weigh on every side of the comparison.&lt;/p&gt;&lt;h3&gt;Go&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#go&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Go is the incumbent. It&amp;#39;s what Bluesky&amp;#39;s own production DataPlane is written in,
and the fit is strong. Goroutines and channels map cleanly onto &amp;quot;fan out work,
gather results, respond.&amp;quot; The bitmap work is comfortable in Go (GraphD itself is
Go). gRPC support is first-class, deployment is a single static binary, and the
tooling is mature. In-process fan-out is doable with worker pools draining
buffered channels.&lt;/p&gt;&lt;p&gt;The costs sit at the extremes. Under heavy allocation Go&amp;#39;s garbage collector
starts eating CPU, and at very high socket counts the network backend can
bottleneck on syscalls. Both respond to runtime tuning, but the tuning is
ongoing work, not a one-time fix. And the in-process fan-out you build yourself
comes with no supervision or isolation layer: a panicking worker takes the
process down, and backpressure and lifecycle management would be our&amp;#39;s to write.&lt;/p&gt;&lt;h3&gt;Rust&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#rust&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Rust gives the highest ceiling and the tightest control. No garbage collector
means no GC pauses in the tail latency. Tokio handles enormous concurrency with
low per-task overhead, tonic covers gRPC, and the roaring-bitmap and datastore
libraries are excellent. For the compute-bound half of our workload, nothing
beats it.&lt;/p&gt;&lt;p&gt;The cost is velocity. As this service is thin on business logic, we would pay
the price of the borrow checker and the sharp edges of async Rust on every line
while protecting very little. Fan-out with Tokio tasks and channels works very
well. However, as with Go, we would need to build lifecycle, backpressure and
supervision manually.&lt;/p&gt;&lt;h3&gt;Node / TypeScript&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#node--typescript&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Node deserves real consideration because it&amp;#39;s the language of the reference
DataPlane and the rest of the atproto stack: PDS, AppView frontend, lexicons.
One language across the codebase, shared types from lexicon definitions, the
largest hiring pool, the fastest iteration. For the cold path&amp;#39;s I/O-bound record
fetches it&amp;#39;s fine, and for modest-scale self-hosting it&amp;#39;s a sensible default.&lt;/p&gt;&lt;p&gt;The hot path is where it breaks down. One event loop per process means in-memory
queues and request serving compete for the same loop, and any CPU-bound work
blocks both. Using multiple cores means multiple processes with no shared
memory, which brings back the cross-process coordination the in-process design
was meant to remove, and makes a large shared in-memory graph awkward. It&amp;#39;s the
right tool for the reference implementation and a strained one for a
throughput-oriented production server.&lt;/p&gt;&lt;h3&gt;Elixir&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#elixir&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Elixir runs on the BEAM, a runtime built for massive numbers of cheap, isolated,
preemptively scheduled processes. Per-process garbage collection means no global
stop-the-world pauses, so tail latency stays flat under load. Supervision trees
give fault isolation and recovery essentially for free. For high-concurrency
request serving and a backpressured in-process queue, it&amp;#39;s the most naturally
suited runtime of the four.&lt;/p&gt;&lt;p&gt;It has one well-known weakness: raw per-core compute. The BEAM optimises for
concurrency and consistent latency, not single-threaded number-crunching.
Byte-level work like set operations over a large follower graph is exactly where
it&amp;#39;s slowest. Taken at face value, that rules it out for a workload with heavy
bitmap operations at its core.&lt;/p&gt;&lt;h2&gt;Every candidate has a flaw — which ones can you fix?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#every-candidate-has-a-flaw--which-ones-can-you-fix&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Four reasonable options, each with one thing standing between it and a clean
fit. Instead of arguing it on paper, we prototyped toward each remedy, far
enough to tell whether the flaw could be engineered away or was structural.&lt;/p&gt;&lt;p&gt;Go&amp;#39;s GC and syscall overheads respond to tuning, and Bluesky&amp;#39;s production
DataPlane proves you can push Go a long way. But the tuning never ends. It&amp;#39;s a
knob you keep turning for the life of the service.&lt;/p&gt;&lt;p&gt;Node&amp;#39;s single event loop has exactly one fix, running more processes, and that
fix recreates the cross-process coordination and shared-state problems we were
designing out. There&amp;#39;s no way around it within the language.&lt;/p&gt;&lt;p&gt;Rust has no performance flaw. Its cost showed up as soon as we started building:
hand-rolled concurrency, lifecycle and backpressure machinery on every path, for
a service with little logic to protect. We realised that that cost would not
shrink over time, but stay with us with every change we would need to make.&lt;/p&gt;&lt;p&gt;Elixir&amp;#39;s flaw, per-core compute on tight loops, is real, and we hit it where
you&amp;#39;d expect: the follower-graph set operations. But it stayed in one place. It
didn&amp;#39;t smear across the service. It sat in a single, well-defined component we
could draw a boundary around, and a component with a sharp boundary can be
replaced. That&amp;#39;s what sent us back to Elixir for a second look.&lt;/p&gt;&lt;h2&gt;Resolving the Elixir paradox&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#resolving-the-elixir-paradox&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Can that component actually be lifted out? Yes, and the reason has less to do
with Elixir than with how our system is laid out.&lt;/p&gt;&lt;p&gt;Start with the claim itself, because it&amp;#39;s often stated too broadly. &amp;quot;The BEAM is
slow at CPU work&amp;quot; is true for tight numeric loops on a single core. It is not a
claim about how much hardware a real service needs. Most of what a production
server spends its time on isn&amp;#39;t a tight loop; it&amp;#39;s keeping thousands of
concurrent requests moving, and the BEAM is efficient at exactly that.&lt;/p&gt;&lt;p&gt;Per-process garbage collection means no global pauses, and tail latency stays
flat enough that you can run a node closer to its limit and still hit your
latency target.&lt;/p&gt;&lt;p&gt;This is why Elixir services often need fewer machines than the Go or Node
version of the same thing, occasionally approaching Rust, even though they lose
every microbenchmark. Hardware cost under concurrency and single-core throughput
are two different measurements, and only one of them shows up on your bill.&lt;/p&gt;&lt;p&gt;That said, our workload really does contain the thing the BEAM is bad at: the
roaring-bitmap intersections and unions over the follow graph. We don&amp;#39;t want to
talk our way around that, so we move it off the BEAM entirely.&lt;/p&gt;&lt;p&gt;The follower graph lives in a Rust implementation of Roaring Bitmaps, called
from Elixir as a NIF (Native Implemented Function). What makes this more than a
generic &amp;quot;wrap the slow part in Rust&amp;quot; patch is the shape of the data flow:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The input crossing the boundary is tiny: a user ID, or a small set of IDs.&lt;/li&gt;&lt;li&gt;The graph itself, millions of edges, stays on the Rust side in native memory.
The BEAM never holds it, never copies it, never garbage-collects it.&lt;/li&gt;&lt;li&gt;The expensive compute, the intersections and unions, runs entirely inside Rust
at native speed.&lt;/li&gt;&lt;li&gt;Only the result crosses back, and because timelines are length-limited, that
result is small by construction.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The usual objection to NIFs is the copy cost at the boundary. But in our case,
only small data crosses in both directions while the big structure and the heavy
computation stay native, which is the ideal case. That isn&amp;#39;t luck. The
timeline-length limit we&amp;#39;d already committed to is what bounds the return
values.&lt;/p&gt;&lt;p&gt;This subtracts the BEAM&amp;#39;s weakness from the hot path and keeps its strengths.
The compute-bound half runs in Rust regardless of host language. What&amp;#39;s left for
Elixir is orchestration: high-concurrency request serving and the fan-out queue,
the regime where the BEAM is at its best and where our own production experience
says it&amp;#39;s most efficient.&lt;/p&gt;&lt;p&gt;Of course, the NIF has its own price. It runs inside the BEAM&amp;#39;s memory space, so
a crash in the Rust code can take down the VM, and a long-running call can stall
a scheduler. So we give up some of the &amp;quot;let it crash&amp;quot; isolation exactly at the
native boundary. The mitigations fit this case well: the calls are short (small
in, bounded compute, small out), and the Rust surface is small, stable, and the
kind of code that rarely changes once written. Two languages is a real
maintenance cost, but &lt;a href=&quot;https://hexdocs.pm/rustler&quot;&gt;Rustler&lt;/a&gt; keeps the boundary
ergonomic, and a small, bounded Rust core seemed the cheapest of the available
evils.&lt;/p&gt;&lt;h2&gt;The deciding factor: fan-out as code, not infrastructure&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-deciding-factor-fan-out-as-code-not-infrastructure&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The NIF made Elixir competitive. The fan-out requirement made it the choice.&lt;/p&gt;&lt;p&gt;Recall the spec: a deferrable queue that absorbs bursts, applies backpressure,
delivers within minutes, and degrades gracefully when the backlog grows. In Go,
Rust or Node, that almost always becomes an external system — Redis, NATS,
RabbitMQ, Kafka — because the language doesn&amp;#39;t give you primitives to do it
safely in-process.&lt;/p&gt;&lt;p&gt;On the BEAM, those primitives are the language. Processes are the workers,
mailboxes are the queues, supervisors handle recovery, and
&lt;a href=&quot;https://hexdocs.pm/gen_stage&quot;&gt;GenStage&lt;/a&gt; and
&lt;a href=&quot;https://hexdocs.pm/broadway&quot;&gt;Broadway&lt;/a&gt; add explicit backpressure, all inside
one VM with no network hop and nothing extra to deploy. Several costs disappear
outright:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;No serialisation across a queue boundary. An external queue serialises every
fan-out job on push and deserialises it on pop, putting per-event encoding
cost right back on the hot path we&amp;#39;d worked to keep it off. In-process, a job
is a message between processes.&lt;/li&gt;&lt;li&gt;No second system to operate. No separate scaling story, no &amp;quot;is Redis the
bottleneck now,&amp;quot; no disagreement between the service&amp;#39;s view of the backlog and
the queue&amp;#39;s.&lt;/li&gt;&lt;li&gt;One failure model. Supervision covers fan-out workers the same way it covers
everything else. There&amp;#39;s no seam between &amp;quot;the service crashed&amp;quot; and &amp;quot;the queue
is in a weird state.&amp;quot;&lt;/li&gt;&lt;li&gt;Backpressure in-band. Producer and consumer share a runtime, so the producer
notices the consumer falling behind directly instead of inferring it from
queue-depth metrics after the fact.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The trade is durability. In-process means in-memory, and an in-memory backlog
dies with the node. For us that&amp;#39;s acceptable: fan-out is best-effort timeline
population, timelines are bounded and ageing, and the fan-in path on read covers
whatever goes missing for a window. We&amp;#39;re choosing fast, simple and
lossy-on-crash over durable, external and heavier, and the choice only works
because the rest of the architecture makes the loss cheap. If you need
durability here, much of the in-process advantage narrows. Check this assumption
against your own tolerances.&lt;/p&gt;&lt;h2&gt;Why Elixir&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#why-elixir&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We love working with Elixir, but we did not assume it to be the tool of choice
for this project. The ecosystem just did not seem to be waiting for it. Choosing
it means making the case for it, repeatedly, starting with this post.&lt;/p&gt;&lt;p&gt;And Elixir doesn&amp;#39;t win every category. We came to the decision to use it
because, for this specific workload, the one axis it loses on — per-core compute
for graph operations — is the one we can cleanly offload to a small Rust NIF,
with a data flow that makes the offload nearly free.&lt;/p&gt;&lt;p&gt;Everything that remains is concurrency, coordination, predictable tail latency
under load, and a burst-absorbing fan-out queue we can build in the program
itself instead of bolting on as infrastructure. Go would have been the
pragmatic, proven middle.&lt;/p&gt;&lt;p&gt;Rust would have given us the highest ceiling at the cost of velocity and a lot
of hand-rolled concurrency machinery. Node was right for the reference
implementation and wrong for a memory-bound production server. Elixir plus a
thin Rust core covers both halves of a workload that genuinely has two halves.&lt;/p&gt;&lt;h2&gt;What next?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#what-next&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Most of this is architecture-level reasoning informed by the workload&amp;#39;s shape
and each runtime&amp;#39;s known characteristics. We backed this up with head-to-head
benchmarks between the Node reference implementation and our Elixir POC.&lt;/p&gt;&lt;p&gt;For that, we needed to build a performance evaluation toolkit for our studies.
If you want to find out the limits of your setup,
&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;check it out&lt;/a&gt;&lt;/p&gt;&lt;p&gt;The pieces we lean on hardest are well-sourced: Jaz&amp;#39;s GraphD work for the
in-memory graph and Roaring Bitmaps, and the documented existence of a
fan-in/fan-out split between expensive paging and interactive-speed set
intersection. The pieces we&amp;#39;re least certain about - the exact timeline window,
the precise per-request CPU breakdown - we&amp;#39;ve flagged as such.&lt;/p&gt;&lt;p&gt;We have build and tested a
&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir&quot;&gt;proof of concept&lt;/a&gt; that
serves the skeleton amazingly fast. What is missing now is the boring part of
writing the &amp;gt; 90 endpoints of the DataPlane API and the indexer plugins.
Currently, it&amp;#39;s a side project.
&lt;a href=&quot;https://cal.eu/bitcrowd/discuss-a-project&quot;&gt;Give us a shout&lt;/a&gt; if you want to help
us take it further, faster.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Your Orchestrator Is a Finite Automaton in Denial</title>
<link>https://rocket-science.ru/hacking/2026/06/14/status-driven-orchestrator-in-denial</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">5ghBBH0F5npiisbndTG8m73jcj-6rvTi17EXwg==</guid>
<pubDate>Tue, 16 Jun 2026 03:03:39 +0000</pubDate>
<description>On the lonely `status` column that quietly metastasised into a state machine, the boolean flags breeding in its shadow, and why a real finite automaton is not academic finery but the cheapest insurance you will ever decline to buy</description>
<content:encoded>&lt;p&gt;Somewhere in your codebase there is a table with a column called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt;. It started life as the most innocent thing imaginable—a single string, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;quot;pending&amp;quot;&lt;/code&gt;, set once at insertion and forgotten. Then somebody needed to know whether the thing had been paid for, so a boolean joined the schema. Then somebody needed to know whether it had shipped, and another boolean arrived. By the time the quarter closed, your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; column had accreted flags the way a ship’s hull accretes barnacles: quietly, asymmetrically, and always below the waterline where nobody looks until the thing stops steering.&lt;/p&gt;&lt;p&gt;This is &lt;em&gt;status-driven orchestration&lt;/em&gt;, and it is the most popular way in the industry to build a finite-state machine while loudly insisting you are doing nothing of the sort. The denial is the interesting part. Everyone agrees that explicit state machines are good in the abstract—the way everyone agrees that flossing is good—and then goes home and writes another &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond/1&lt;/code&gt; that branches on a string.&lt;/p&gt;&lt;p&gt;Let me try to talk you out of it.&lt;/p&gt;&lt;h2&gt;The Anatomy of the Thing You Built&lt;/h2&gt;&lt;p&gt;Here is a perfectly representative specimen. An order goes through a fulfilment pipeline: it gets paid for, packed, shipped, delivered. It can be cancelled. It can be refunded. Nothing exotic. Let us model it the way it actually gets modelled in the wild, in an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ecto&lt;/code&gt; schema that has clearly survived three product managers:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;schema &amp;quot;orders&amp;quot; do
  field :status,     :string,  default: &amp;quot;pending&amp;quot;
  field :paid?,      :boolean, default: false
  field :packed?,    :boolean, default: false
  field :shipped?,   :boolean, default: false
  field :delivered?, :boolean, default: false
  field :cancelled?, :boolean, default: false
  field :refunded?,  :boolean, default: false
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;And here is the orchestrator that drives it—the beating heart of the system, the thing that gets paged at three in the morning:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;def advance(%Order{} = order) do
  cond do
    order.status == &amp;quot;pending&amp;quot; and order.paid? and not order.cancelled? -&amp;gt;
      update(order, status: &amp;quot;paid&amp;quot;)

    order.status == &amp;quot;paid&amp;quot; and order.packed? and not order.refunded? -&amp;gt;
      update(order, status: &amp;quot;packed&amp;quot;)

    order.status == &amp;quot;packed&amp;quot; and order.shipped? -&amp;gt;
      update(order, status: &amp;quot;shipped&amp;quot;)

    order.status == &amp;quot;shipped&amp;quot; and order.delivered? -&amp;gt;
      update(order, status: &amp;quot;delivered&amp;quot;)

    order.cancelled? -&amp;gt;
      update(order, status: &amp;quot;cancelled&amp;quot;)

    true -&amp;gt;
      {:ok, order}
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It looks reasonable. It compiles. It passes the one test somebody wrote for the happy path. And it is, I regret to inform you, a catastrophe wearing the high-visibility vest of a solution.&lt;/p&gt;&lt;h2&gt;What Is Actually Wrong Here&lt;/h2&gt;&lt;h3&gt;Illegal states are not merely possible, they are the majority&lt;/h3&gt;&lt;p&gt;Count the representable states. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string takes seven values; each of the six booleans doubles the space. That is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;7 × 2⁶ = 448&lt;/code&gt; distinct rows your database will cheerfully accept. Of those four hundred and forty-eight, perhaps seven correspond to an order that could exist in physical reality.&lt;/p&gt;&lt;p&gt;The other four hundred and forty-one are nonsense, and your schema treats them with exactly the same hospitality as the legal ones. Nothing—not a constraint, not a type, not a stern comment—prevents a row with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status: &amp;quot;delivered&amp;quot;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancelled?: true&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refunded?: true&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid?: false&lt;/code&gt;. That is an order that was never paid for, was cancelled, was refunded money it never received, and was nonetheless delivered. It sits in your database like a passenger holding a ticket for a train that was cancelled: still on the platform, still expecting to be taken somewhere, and now also somehow already at the destination.&lt;/p&gt;&lt;p&gt;The flags breed in the schema like adapters in a junk drawer—each one solved a real problem exactly once, and now you own six and can confidently explain three. Every new boolean does not &lt;em&gt;add&lt;/em&gt; a state; it &lt;em&gt;multiplies&lt;/em&gt; the space of states you have promised to reason about and silently declined to.&lt;/p&gt;&lt;h3&gt;The state machine exists; you simply refused to draw it&lt;/h3&gt;&lt;p&gt;There is a finite-state machine in that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;advance/1&lt;/code&gt; function. It is real, it has transitions, it has rules about what may follow what. The only problem is that it has no single, inspectable existence. It is smeared across the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt;, the changeset validations, three controller actions, a background job, and the part of the senior engineer’s memory that he is planning to take with him when he leaves. The transition table lives nowhere and everywhere at once, like a signature forged by committee.&lt;/p&gt;&lt;p&gt;Ask the system a simple question—&lt;em&gt;what states can follow &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid&lt;/code&gt;?&lt;/em&gt;—and there is no honest way to answer it except to read the entire codebase and hope you found every site that writes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt;. Spoiler: you did not. There is one in a Rake-equivalent task from 2023 that sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status = &amp;quot;shipped&amp;quot;&lt;/code&gt; directly, bypassing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;advance/1&lt;/code&gt; entirely, because someone needed to fix a stuck order once and never removed the scaffolding.&lt;/p&gt;&lt;h3&gt;Nothing stops a transition that should be unthinkable&lt;/h3&gt;&lt;p&gt;Because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; is just a field, &lt;em&gt;any&lt;/em&gt; code that can reach the struct can write &lt;em&gt;any&lt;/em&gt; value to it. There is no notion of a transition being illegal—there is only the notion of you having remembered to write an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt; that forbids it, everywhere, forever, without exception. Going from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delivered&lt;/code&gt; back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt; is not prevented by the design; it is prevented by your vigilance, which is a renewable resource right up until the on-call rotation hits someone new.&lt;/p&gt;&lt;h3&gt;Concurrency turns the whole thing into a knife fight in a lift&lt;/h3&gt;&lt;p&gt;Two requests arrive at once. Both read the order in state &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt;. Both evaluate the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt;. Both decide the next state. Both write. What follows is less a race condition than a knife fight in a lift: cramped, badly lit, and with exactly one party walking out. Last writer wins, the first update evaporates, and the customer is charged twice because the “already paid?” check read a value that was true a microsecond ago and is now a lie.&lt;/p&gt;&lt;p&gt;You can paper over this with row locks and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR UPDATE&lt;/code&gt; and optimistic-concurrency version columns, and now you are hand-rolling the serialisation guarantees that a process-based state machine hands you for nothing.&lt;/p&gt;&lt;h3&gt;Failure is an afterthought wearing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rescue&lt;/code&gt;&lt;/h3&gt;&lt;p&gt;Where, in the specimen above, does failure live? It does not. When the payment processor times out, the code raises, something up the stack catches it, sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status: &amp;quot;failed&amp;quot;&lt;/code&gt;—an eighth string value nobody added to the schema’s mental model—and the order joins the population of rows that no longer match any branch of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt; and will sit there, inert, until a human notices the revenue gap.&lt;/p&gt;&lt;h3&gt;You overwrote your own audit trail&lt;/h3&gt;&lt;p&gt;Every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;update(order, status: &amp;quot;paid&amp;quot;)&lt;/code&gt; destroys the evidence of where the order was a moment ago. The history of the process—the single most valuable thing you have when debugging why an order is stuck—is overwritten in place. Reconstructing it later is archaeology conducted with a teaspoon and a head-torch, cross-referencing log lines against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updated_at&lt;/code&gt; timestamps and praying nobody ran a backfill.&lt;/p&gt;&lt;h2&gt;Now Do It With an Actual Finite Automaton&lt;/h2&gt;&lt;p&gt;Here is the same pipeline as a real FSM, using my &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt;&lt;/a&gt; library. The entire state machine—every state, every legal transition, every event that triggers it—is declared once, in plain text, in a format that is simultaneously the code, the documentation, and a diagram your product manager can read:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;defmodule Order.FSM do
  @fsm &amp;quot;&amp;quot;&amp;quot;
  pending --&amp;gt; |pay| paid
  pending --&amp;gt; |cancel| cancelled
  pending --&amp;gt; |expire| cancelled
  paid --&amp;gt; |pack| packed
  paid --&amp;gt; |refund?| refunded
  packed --&amp;gt; |ship| shipped
  shipped --&amp;gt; |deliver| delivered
  &amp;quot;&amp;quot;&amp;quot;

  use Finitomata, fsm: @fsm, auto_terminate: true, timer: 15 * 60_000
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;That is not pseudocode and it is not a comment. That &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@fsm&lt;/code&gt; string is parsed, validated, and compiled into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt; with all the transition machinery generated for you. The diagram &lt;em&gt;is&lt;/em&gt; the source of truth, because there is no other source for it to disagree with.&lt;/p&gt;&lt;p&gt;Then you implement the business logic, and &lt;em&gt;only&lt;/em&gt; the business logic, in callbacks that pattern-match on exactly the state-and-event pairs you care about:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;@impl Finitomata
def on_transition(:pending, :pay, %{amount: amount}, payload) do
  case Payments.charge(payload.customer, amount) do
    {:ok, receipt} -&amp;gt; {:ok, :paid, Map.put(payload, :receipt, receipt)}
    {:error, _reason} -&amp;gt; {:error, :payment_declined}
  end
end

def on_transition(:paid, :pack, _event_payload, payload),
  do: {:ok, :packed, payload}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A successful charge moves the machine to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:paid&lt;/code&gt; and stashes the receipt in the payload. A declined charge returns an error, the machine &lt;em&gt;stays exactly where it was&lt;/em&gt;, and control flows to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure/3&lt;/code&gt;—a first-class, named place for things going wrong, rather than a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rescue&lt;/code&gt; clause hoping for the best:&lt;/p&gt;&lt;p&gt;Time itself becomes a transition rather than a shadow orchestrator. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer: 15 * 60_000&lt;/code&gt; option calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_timer/2&lt;/code&gt; on a schedule, so an unpaid order expires on its own, without a cron job somewhere running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE status = &amp;#39;pending&amp;#39; AND inserted_at &amp;lt; ...&lt;/code&gt; and mutating rows behind the FSM’s back:&lt;/p&gt;&lt;p&gt;And driving it from the outside is unceremonious:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;{:ok, _pid} = Finitomata.start_link()

Finitomata.start_fsm(Order.FSM, &amp;quot;order:42&amp;quot;, %{customer: cust, amount: 9_900})
Finitomata.transition(&amp;quot;order:42&amp;quot;, {:pay, %{amount: 9_900}})

Finitomata.state(&amp;quot;order:42&amp;quot;)
#⇒ %Finitomata.State{current: :paid, history: [:pending], payload: %{…}}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Notice the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history&lt;/code&gt; field. The machine remembers where it has been, for free, without you overwriting anything.&lt;/p&gt;&lt;h2&gt;Point by Point, Why This One Wins&lt;/h2&gt;&lt;h3&gt;Illegal states stop being representable&lt;/h3&gt;&lt;p&gt;The current state is a single atom drawn from a closed set the compiler knows about. There is no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string &lt;em&gt;and&lt;/em&gt; a constellation of booleans to fall out of sync; there is the state, and there is the payload, and they are different things with different jobs. The four hundred and forty-one nonsense rows simply have nowhere to live. You cannot be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delivered&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancelled&lt;/code&gt; simultaneously for the same brutally simple reason you cannot be in two rooms at once: the model does not have a word for it.&lt;/p&gt;&lt;h3&gt;The transition table is validated before your code ever runs&lt;/h3&gt;&lt;p&gt;This is the part that ought to close the argument by itself. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:finitomata&lt;/code&gt; compiler reads your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@fsm&lt;/code&gt; declaration and refuses to proceed unless it is a &lt;em&gt;consistent&lt;/em&gt; machine: exactly one initial state, at least one final state, and no orphans—no state you can enter and never leave, no state you declared and can never reach. It refuses to compile an incoherent machine the way a good editor refuses a sentence that parses but lies.&lt;/p&gt;&lt;p&gt;Better still, if you add a transition to the diagram and forget to handle an ambiguous case in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_transition/4&lt;/code&gt;, the compiler &lt;em&gt;tells you&lt;/em&gt;, at compile time, with a warning that names the gap. Compare this to the status-driven approach, where a forgotten branch is discovered by a customer, on a Saturday, via Twitter.&lt;/p&gt;&lt;h3&gt;Transitions are guarded by construction, not by your memory&lt;/h3&gt;&lt;p&gt;A transition that is not in the diagram does not happen. Sending &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:deliver, …}&lt;/code&gt; to an order in state &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt; is not a bug you must remember to prevent with an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt;; it is structurally impossible, ignored by the machine the way a vending machine ignores a button for a slot that does not exist. The set of legal moves is data, declared once, enforced everywhere, rather than folklore re-implemented at each call site.&lt;/p&gt;&lt;h3&gt;Concurrency is solved because each machine is a process&lt;/h3&gt;&lt;p&gt;Every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt; instance is its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt;, which means transition requests for a single order are serialised through a single mailbox and processed one at a time, in order. The knife-fight-in-a-lift disappears, not because you were careful, but because the architecture made the fight impossible to start. No row locks, no version columns, no optimistic-concurrency retries—just the actor model doing the one thing it has always been excellent at.&lt;/p&gt;&lt;h3&gt;Failure, timeouts, and retries are vocabulary, not accidents&lt;/h3&gt;&lt;p&gt;The library has named, first-class places for the things status-driven code handles by flailing: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure/3&lt;/code&gt; for transitions that did not complete, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_timer/2&lt;/code&gt; for the passage of time, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ensure_entry:&lt;/code&gt; option to retry a transition until it sticks, and the last error preserved in the state for when you need to know &lt;em&gt;why&lt;/em&gt;. A transition that ends with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt;—like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid --&amp;gt; |refund?| refunded&lt;/code&gt;—is declared as one that is &lt;em&gt;expected&lt;/em&gt; to sometimes fail, so it does so quietly, without crying wolf in your logs. A transition that ends with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!&lt;/code&gt; is &lt;em&gt;determined&lt;/em&gt; and fires the instant it becomes the only way forward. These are not features bolted on; they are the grammar of the thing.&lt;/p&gt;&lt;h3&gt;History and observability come included&lt;/h3&gt;&lt;p&gt;The state carries its own history. When an order is stuck, you ask the machine where it has been and it tells you, instead of you reconstructing the past from the sediment of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updated_at&lt;/code&gt;. Pair it with the &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;telemetria&lt;/code&gt;&lt;/a&gt; integration and every transition is an event you can measure, rather than a mutation you have to infer.&lt;/p&gt;&lt;h3&gt;Testing stops being string-equality theatre&lt;/h3&gt;&lt;p&gt;Because the machine is explicit, you can test the machine. &lt;a href=&quot;https://hexdocs.pm/finitomata/Finitomata.ExUnit.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata.ExUnit&lt;/code&gt;&lt;/a&gt; lets you walk a path through the states and assert on each transition and the resulting payload, with a syntax that reads like the thing it verifies:&lt;/p&gt;&lt;p&gt;The status-driven equivalent is mocking the database and asserting that a string equals another string, which tests your typing accuracy and very little else.&lt;/p&gt;&lt;h3&gt;Distribution is a one-line upgrade&lt;/h3&gt;&lt;p&gt;When one node is no longer enough, swap &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt; for &lt;a href=&quot;https://hexdocs.pm/finitomata/Infinitomata.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Infinitomata&lt;/code&gt;&lt;/a&gt; and your machines run transparently across the cluster on top of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:pg&lt;/code&gt;, with no change to the business logic. Scaling becomes a matter of adding nodes, not of rewriting your orchestrator around a distributed lock you will get subtly wrong.&lt;/p&gt;&lt;h2&gt;The Objections, and Why They Fold&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;“This is over-engineering. It’s just a status field.”&lt;/strong&gt; It is not just a status field, and the proof is the six booleans standing next to it. You did not avoid building a state machine; you built one and declined to name it, which is the single option strictly worse than both alternatives—you pay the full cost of the complexity and receive none of the guarantees. Naming the tumour does not create it. It lets you operate.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“FSMs are academic, textbook stuff.”&lt;/strong&gt; So is binary search, which you use without flinching, and the hash table underneath every dictionary you have ever instantiated. “Academic” is what we call the ideas that turned out to be so correct they became invisible. The finite automaton is one of the oldest, most thoroughly understood objects in computer science. Refusing to use it because it has a formal name is like refusing to use a bridge because an engineer was involved.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“We already use a workflow engine for this.”&lt;/strong&gt; A workflow engine is, in the cases that matter, a finite-state machine that hired a sales team and learned to bill by the seat. If you want the semantics, you can have them in a hundred and fifty lines of your own language, in your own repository, without the YAML, the vendor console, and the per-execution pricing. Sometimes the heavy engine is the right call. Usually it is a sledgehammer rented monthly to drive a thumbtack.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“Our orchestrator works fine.”&lt;/strong&gt; So does a car with insulating tape over the check-engine light. “Works fine” is a statement about the inputs you have happened to receive so far, not about the four hundred and forty-one illegal states patiently waiting for the input that produces them.&lt;/p&gt;&lt;h2&gt;The Point&lt;/h2&gt;&lt;p&gt;You are going to build a state machine. That decision was made the moment your process had more than one step. The only choice left to you is whether it will be an &lt;em&gt;explicit&lt;/em&gt; state machine—declared in one place, validated by the compiler, guarded by construction, serialised by the runtime, and testable as a unit—or an &lt;em&gt;implicit&lt;/em&gt; one, scattered across your codebase like cutlery after an earthquake, held together by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string, a fistful of booleans, and the fervent hope that nobody writes to the field from somewhere you forgot about.&lt;/p&gt;&lt;p&gt;The implicit machine is not simpler. It is the same machine with the guarantees filed off and the documentation set on fire. Name it. Draw it. Let the compiler check it. Your three-in-the-morning self, squinting at a row that claims to be delivered and refunded and never paid for, will thank you with a sincerity your waking self is not capable of.&lt;/p&gt;&lt;p&gt;Happy—and finite—automating.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;Previously, on the subject of refusing to lose your mind over state:&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://rocket-science.ru/hacking/2024/03/03/finitomata-for-the-win&quot;&gt;Finitomata FTW&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rocket-science.ru/hacking/2024/05/24/finitomata-exunit&quot;&gt;Make Your Library Test-Friendly&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded>
</item>
<item>
<title>Serving timelines from Elixir</title>
<link>https://bitcrowd.dev/timelines-from-elixir</link>
<guid isPermaLink="false">olUyjOHUSPkBNpoVkLx3lMKVxZn6x4b-wcqAdw==</guid>
<pubDate>Tue, 02 Jun 2026 04:24:30 +0000</pubDate>
<description>Recently, we presented our performance toolkit for atproto including a small Proof-of-Concept for a dataplane written in Elixir.</description>
<content:encoded>&lt;p&gt;Recently, we presented our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;performance toolkit for atproto&lt;/a&gt; including a small Proof-of-Concept for a dataplane written in Elixir.
We didn&amp;#39;t go too much into the details though.
We will make up for that in this post.&lt;/p&gt;&lt;p&gt;Let&amp;#39;s start by recapping the problem.&lt;/p&gt;&lt;p&gt;The &lt;a href=&quot;https://atproto.com/&quot;&gt;AT Protocol&lt;/a&gt; enables us to build an open social ecosystem of interoperable applications.
By far the most popular application in this ecosystem today is &lt;a href=&quot;https://bsky.app/&quot;&gt;Bluesky&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Bluesky has open sourced almost all of their software.
You can find their &lt;a href=&quot;https://github.com/bluesky-social/social-app&quot;&gt;app&lt;/a&gt;, their &lt;a href=&quot;https://github.com/bluesky-social/indigo&quot;&gt;Go services&lt;/a&gt; and their &lt;a href=&quot;https://github.com/bluesky-social/atproto&quot;&gt;TypeScript code&lt;/a&gt; (backend services and protocol reference implementation).&lt;/p&gt;&lt;p&gt;This is their production code, with one exception: they don&amp;#39;t run the open source dataplane.
They&amp;#39;ve replaced it with a closed source implementation that is more performant.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/bluesky-5fcedfef78dd330f8c008f30b0457a02.png&quot; alt=&quot;Bluesky architecture overview&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The dataplane is a central component of the AppView in Bluesky&amp;#39;s architecture. Read the&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;previous post&lt;/a&gt;to learn more.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;We talked about some of the issues of the open source dataplane in our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#the-problems-of-the-dataplane&quot;&gt;previous blog post&lt;/a&gt;.&lt;br/&gt;&lt;strong&gt;The bottom line is&lt;/strong&gt;: it won&amp;#39;t scale if you&amp;#39;re trying to serve hundreds of thousands of users.&lt;/p&gt;&lt;h2&gt;Why you should care&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#why-you-should-care&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Even the Bluesky people think that &lt;a href=&quot;https://atproto.com/blog/protocol-check-in-fall-2025#hard-decentralization&quot;&gt;hard decentralization&lt;/a&gt; is necessary.
What is the point of an open ecosystem when everything is tied to the existence of a single company?&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Bluesky can be bought and start to enshittify&lt;/li&gt;&lt;li&gt;Bluesky operates under US law, why should we want our online social life to be ruled by mad people inside a white house&lt;/li&gt;&lt;li&gt;Bluesky has &lt;a href=&quot;https://pckt.blog/b/jcalabro/april-2026-outage-post-mortem-219ebg2&quot;&gt;outages&lt;/a&gt;, alternatives could continue to operate&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;So, we should want alternative providers of Bluesky to exist.&lt;/p&gt;&lt;p&gt;Luckily for us, there is already a number of organizations picking up that work.&lt;br/&gt;Luckily for them, they can build on the open source components that Bluesky provides.&lt;br/&gt;Unluckily for them, they are left with a dataplane implementation that won&amp;#39;t work for them when their userbase grows.&lt;/p&gt;&lt;p&gt;As a result, the Blacksky people have to spend their time rewriting &lt;a href=&quot;https://github.com/blacksky-algorithms/rsky/tree/main/rsky-wintermute&quot;&gt;components in Rust&lt;/a&gt; and our friends from Eurosky started exploring &lt;a href=&quot;https://github.com/yawn/crimeline&quot;&gt;alternative ways to build timelines&lt;/a&gt;.&lt;/p&gt;&lt;h2&gt;Serving timelines from Elixir&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#serving-timelines-from-elixir&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Elixir provides a number of benefits for a backend service like the dataplane.&lt;br/&gt;You work on a high level of abstraction, getting more done in the same amount of time and end up with very readable code (yes, this is still important, even for your agent!).&lt;br/&gt;At the same time you get parallelism for free and performance that scales with the cores of the system.&lt;br/&gt;Errors are isolated, so they don&amp;#39;t bring down the whole service.
As a bonus, the VM provides you with good tooling for introspection and observability, all out of the box.&lt;/p&gt;&lt;p&gt;So, when we built our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#a-performant-open-source-dataplane&quot;&gt;Proof-of-Concept&lt;/a&gt;, we naturally chose Elixir.
We wanted to investigate the performance issues that will come up in the open source dataplane implementation.
For this reason, we focused on implementing the critical endpoint which is responsible for serving timelines to users.&lt;/p&gt;&lt;p&gt;From our exploration in the last blog post, we already know that the most important decision to solve the issues of Bluesky&amp;#39;s open source database implementation is to change the approach from fan-in to fan-out.&lt;/p&gt;&lt;p&gt;When a user requests their timeline from Bluesky&amp;#39;s open source database implementation it will query the posts that should be in the timeline on demand.
This is known as fan-in principle and is a major reason for the performance issues of this implementation.&lt;/p&gt;&lt;p&gt;Consequently, the implementation used by Bluesky in production turns this process on the head, known as the fan-out principle.
It constructs the timeline for each user whenever a new post arrives in the system.
When the user requests their timeline, the data is already waiting for them to be served.&lt;/p&gt;&lt;h3&gt;ETS tables&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#ets-tables&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;With this insight, it was clear to us that we should build our dataplane on the fan-out principle.
It was less clear how we should store the data we need.
The Erlang VM has multiple built-in options for storing data, so choosing one of them, the &lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;Erlang Term Storage&lt;/a&gt; (ETS), was a good starting point.&lt;/p&gt;&lt;p&gt;Citing the documentation:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Data is organized as a set of dynamic tables, which can store tuples.&lt;br/&gt;...&lt;br/&gt;Tables are divided into four different types, set, ordered_set, bag, and duplicate_bag. A set or ordered_set table can only have one object associated with each key. A bag or duplicate_bag table can have many objects associated with each key.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;ETS stores data as tuples in tables in memory but which tables do we need to store all the data we need to serve timelines?
And of which type should the tables be?&lt;/p&gt;&lt;p&gt;To store follows in a way that we can retrieve all the information we need efficiently, we actually need two tables: &lt;code&gt;followers_table&lt;/code&gt; and &lt;code&gt;following_table&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@followers_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true
])

:ets.new(@following_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;These store the follows information in both directions, holding pairs of user IDs.&lt;br/&gt;We can find all the followers of a user by looking up the entries with their user ID as key in the &lt;code&gt;followers_table&lt;/code&gt;.
And we can find all the users this user follows by looking up the entries with their user ID as key in the &lt;code&gt;following_table&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Both tables are of type &lt;code&gt;:duplicate_bag&lt;/code&gt;.&lt;br/&gt;We need multiple entries per key, so we must use either &lt;code&gt;:bag&lt;/code&gt; or &lt;code&gt;:duplicate_bag&lt;/code&gt; as type of the table.
&lt;code&gt;:bag&lt;/code&gt; would have the benefit of preventing duplicates, however we chose &lt;code&gt;:duplicate_bag&lt;/code&gt; for its performance benefits.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;posts_table&lt;/code&gt; represents the source of truth for all the posts in the system.
It&amp;#39;s a &lt;code&gt;:set&lt;/code&gt; storing post IDs as we don&amp;#39;t want duplicates here.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@posts_table,[
:set,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;In the &lt;code&gt;feeds_table&lt;/code&gt; we store the feeds of all users.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@feeds_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Whenever a new post arrives in our system, we look up all the followers of the author of the post using the &lt;code&gt;followers_table&lt;/code&gt;.
Then, for each follower we store a pair of the follower&amp;#39;s user ID and the post ID in the &lt;code&gt;feeds_table&lt;/code&gt;.
We chose the type &lt;code&gt;:duplicate_bag&lt;/code&gt; here for the same reasons as we did for the &lt;code&gt;followers_table&lt;/code&gt;.
We need multiple entries per key (user ID) and we want the performance benefits of &lt;code&gt;:duplicate_bag&lt;/code&gt; over &lt;code&gt;:bag&lt;/code&gt;.&lt;/p&gt;&lt;h4&gt;Celebrities&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#celebrities&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;With these tables we would have a functioning system.
However, there is still the &lt;code&gt;celebrity_posts_table&lt;/code&gt; to talk about.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@celebrity_posts_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This table is actually a first optimization of the system.
You might have wondered what would happen if a post arrives in the system authored by a user who has many many followers.
If we would stick to our &lt;code&gt;feeds_table&lt;/code&gt;, we would have to loop through all of them to insert the corresponding data.&lt;/p&gt;&lt;p&gt;For this reason, we introduced the &lt;code&gt;celebrity_posts_table&lt;/code&gt;.
The key idea here is that if a user has too many followers (is a celebrity), we give their posts a special treatment to avoid looping through all the followers and the associated performance issues.
Instead we will store pairs of the author and post IDs in &lt;code&gt;celebrity_posts_table&lt;/code&gt;, which is again of type &lt;code&gt;:duplicate_bag&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;definsert_post(post_id, author_id)do
:ets.insert(@posts_table,{post_id, author_id})

  follower_entries =:ets.lookup(@followers_table, author_id)
  limit =fan_out_limit()

if limit ==:infinityorlength(follower_entries)&amp;lt;= limit do
Enum.each(follower_entries,fn{_subject, follower_id}-&amp;gt;
:ets.insert(@feeds_table,{follower_id, post_id})
end)
else
:ets.insert(@celebrity_posts_table,{author_id, post_id})
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;When a user request their timeline, we will first look for their feed in the &lt;code&gt;feeds_table&lt;/code&gt;, then do another lookup in the &lt;code&gt;celebrity_posts_table&lt;/code&gt; to gather posts from celebrities they follow.
The user will then see a combination of those posts.
So, in fact this dataplane combines fan-out (for regular posts in &lt;code&gt;feeds_table&lt;/code&gt;) with fan-in (for celebrity posts in &lt;code&gt;celebrity_posts_table&lt;/code&gt;) for performance reasons.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defget_timeline(user_id)do
  fan_out_posts =
@feeds_table
|&amp;gt;:ets.lookup(user_id)
|&amp;gt;Enum.map(&amp;amp;elem(&amp;amp;1,1))

  celebrity_posts =
@following_table
|&amp;gt;:ets.lookup(user_id)
|&amp;gt;Enum.flat_map(fn{_actor, followed_id}-&amp;gt;
:ets.lookup(@celebrity_posts_table, followed_id)
end)
|&amp;gt;Enum.map(&amp;amp;elem(&amp;amp;1,1))

  fan_out_posts ++ celebrity_posts
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h3&gt;Evaluating our dataplane&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#evaluating-our-dataplane&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;To get an idea about how much performance we gain with our dataplane, we evaluated it using our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#the-outcomes&quot;&gt;simulator&lt;/a&gt;.
The dashboard below shows the results of this comparison.&lt;/p&gt;&lt;p&gt;We can see two blocks of data:&lt;/p&gt;&lt;p&gt;The first run was recorded with Bluesky&amp;#39;s open source dataplane.
We can see a maximum p99 latency of more than 60 ms. Furthermore, we see the throughput of requests per second fluctuating around 600.&lt;/p&gt;&lt;p&gt;In comparison, the block on the right shows recordings of the same simulation with our dataplane. The latencies are tiny, the throughput sits smoothly at 800 requests per second.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/results_simulator-c843918ee3774947ed1e5375d2f9f131.png&quot; alt=&quot;Grafana dashboard showing data for two simulation runs&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;h3&gt;Crimeline comparison&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#crimeline-comparison&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/yawn/crimeline&quot;&gt;Crimeline&lt;/a&gt; is an experimental timeline builder which caught our attention when doing research for this project.
Like our proof of concept, Crimeline is not a complete dataplane implementation.
Unlike our proof of concept, Crimeline focuses on creating efficient datastructures and conveniently comes with synthetic benchmarks for them.&lt;br/&gt;After having seen the end-to-end evaluation of our dataplane, we were curious how ETS tables would perform in those benchmarks.&lt;/p&gt;&lt;p&gt;The &lt;a href=&quot;https://github.com/yawn/crimeline/blob/main/README.md&quot;&gt;README&lt;/a&gt; describes one of Crimeline&amp;#39;s datastructures, the &lt;a href=&quot;https://github.com/yawn/crimeline/blob/main/src/users/map.rs&quot;&gt;UserMap&lt;/a&gt;, as follows:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Sharded adjacency map. Each uid is split via bitmask into shard index (low bits) and backbone index (high bits). Each shard holds a &lt;code&gt;Vec&amp;lt;Vec&amp;lt;Uid&amp;gt;&amp;gt;&lt;/code&gt; — a dense backbone of sorted adjacency lists.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;It&amp;#39;s a struct that holds information whether a user ID is included in it (think your followers on Bluesky) with efficient methods to add and remove user IDs as well as performing the lookup of an ID.&lt;/p&gt;&lt;p&gt;An ETS-based equivalent looks like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleUserMapEtsdo
@user0

def new do
:ets.new(:forward,[:duplicate_bag,:public,{:read_concurrency,true}])
end

defpopulated(n)do
    table =new()
    tuples =for t &amp;lt;-0..(n -1),do:{@user, t *3}
:ets.insert(table, tuples)
    table
end

defadd(table, target)do
:ets.insert(table,{@user, target})
end

defadd_bulk_list(table, targets)do
    tuples =for t &amp;lt;- targets,do:{@user, t}
:ets.insert(table, tuples)
end

defadd_bulk_each(table, targets)do
Enum.each(targets,fn t -&amp;gt;:ets.insert(table,{@user, t})end)
end

defcontains(table, target)do
:ets.match_object(table,{@user, target})!=[]
end

defremove(table, target)do
:ets.delete_object(table,{@user, target})
end

defdestroy(table)do
:ets.delete(table)
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;We can expect this to be a bit slower than the Rust implementation of Crimeline due to the overhead of the Erlang VM.&lt;br/&gt;The table below shows the average time for each operation in &lt;strong&gt;nanoseconds&lt;/strong&gt; when performed with 100 users.&lt;br/&gt;From our results, we can see that ETS tables nevertheless perform relatively well.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;add&lt;/td&gt;&lt;td&gt;18 ns&lt;/td&gt;&lt;td&gt;186 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1360 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1350 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;remove&lt;/td&gt;&lt;td&gt;26 ns&lt;/td&gt;&lt;td&gt;599 ns&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;So overall we were happy but two benchmarks stood out with growing numbers of users: &lt;code&gt;contains_hit&lt;/code&gt; and &lt;code&gt;contains_miss&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Note the units in the following table.
While the Rust UserMap goes from &lt;code&gt;8 ns&lt;/code&gt; to &lt;code&gt;14 ns&lt;/code&gt; (not even 2x) when increasing the number of users from &lt;code&gt;100&lt;/code&gt; to &lt;code&gt;100_000&lt;/code&gt;, ETS goes from &lt;code&gt;1.36 μs&lt;/code&gt; to &lt;code&gt;1.11 ms&lt;/code&gt;, almost 1000x.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of users)&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.36 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.35 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;Why is this so slow?
From the &lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;ETS docs&lt;/a&gt;:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Insert and lookup times in tables of type set are constant, regardless of the table size. For table types bag and duplicate_bag time is proportional to the number of objects with the same key.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;We chose a &lt;code&gt;:duplicate_bag&lt;/code&gt; for the table to improve insert performance.
Now we are paying for that with slow lookups at scale.&lt;/p&gt;&lt;h4&gt;Roaring Bitmaps&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#roaring-bitmaps&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;We didn&amp;#39;t give up here, of course.
So let&amp;#39;s see what we can improve.&lt;/p&gt;&lt;p&gt;During our research, we also came across &lt;a href=&quot;https://jazco.dev/&quot;&gt;Jaz&amp;#39;s&lt;/a&gt; blog and learned how to &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;shrink the size of your datastructures&lt;/a&gt; and go even further with &lt;a href=&quot;https://jazco.dev/2024/04/20/roaring-bitmaps/&quot;&gt;Roaring Bitmaps&lt;/a&gt;.
The latter seems to be a good fit for optimizing our user map datastructure.&lt;/p&gt;&lt;p&gt;Luckily, &lt;a href=&quot;https://hex.pm/packages/rustler&quot;&gt;rustler&lt;/a&gt; lowers the barrier to use Rust crates from Elixir a lot these days.
Conveniently for us, Aaron Gunderson already did the work to wrap the &lt;a href=&quot;https://crates.io/crates/roaring&quot;&gt;roaring crate&lt;/a&gt; in an &lt;a href=&quot;https://hex.pm/packages/roaring&quot;&gt;Elixir package&lt;/a&gt; and &lt;a href=&quot;https://agundy.com/blog/using-roaring-bitmaps-in-elixir/&quot;&gt;blogged about it&lt;/a&gt;.
So, it&amp;#39;s ready for us to use.&lt;/p&gt;&lt;p&gt;As a very brief summary, bitmaps are a suitable datastructure to compress the follower graph in our dataplane.
Instead of storing pairs of &lt;code&gt;{user_id, follower_id}&lt;/code&gt;, we store a single array for each user where each bit represents whether the user with the id at the index follows the user.&lt;/p&gt;&lt;p&gt;Say we have 5 users, and our ids start at 0. If the first four users all follow user with id 4, we would store the following pairs in our ETS table:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# user_id, follower_id
{4, 0}
{4, 1}
{4, 2}
{4, 3}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With a bitmap, we would store a single array of bits for the user with id 4.
If the bit is &lt;code&gt;1&lt;/code&gt;, the corresponding user follows the user with id 4.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# user_id/index:
# 0, 1, 2, 3, 4
 [1, 1, 1, 1, 0]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;You can see how that compresses the required space in memory in this simplified example.&lt;br/&gt;Assuming 1 byte per id, we would have 4 * 2 = 8 bytes of memory in our ETS table variant.
The bitmap however is complete with 5 bits which fit in a single byte.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://roaringbitmap.org/&quot;&gt;Roaring bitmaps&lt;/a&gt; apply further optimizations on top of this basic idea.&lt;/p&gt;&lt;p&gt;With the &lt;code&gt;roaring&lt;/code&gt; package we can plug the Roaring Bitmap into our user map module.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleUserMapRoaringdo
def new do
{:ok, roaring}=RoaringBitmap64.new()
    roaring
end

defpopulated(n)do
    list =for t &amp;lt;-0..(n -1),do: t
{:ok, roaring}=RoaringBitmap64.from_list(list)
    roaring
end

defadd(roaring, target)do
RoaringBitmap64.insert(roaring, target)
end

defadd_bulk_list(roaring, targets)do
for t &amp;lt;- targets,do:RoaringBitmap64.insert(roaring, t)
end

defadd_bulk_each(roaring, targets)do
Enum.each(targets,fn t -&amp;gt;RoaringBitmap64.insert(roaring, t)end)
end

defcontains(roaring, target)do
RoaringBitmap64.contains?(roaring, target)
end

defremove(roaring, target)do
RoaringBitmap64.remove(roaring, target)
end

defdestroy(_roaring)do
:ok
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With that we get the following results.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of users)&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;th&gt;Roaring Bitmaps UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.36 μs&lt;/td&gt;&lt;td&gt;104 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;td&gt;100 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.35 μs&lt;/td&gt;&lt;td&gt;98 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;td&gt;100 ns&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;We&amp;#39;re still slower than Crimeline&amp;#39;s implementation but it&amp;#39;s acceptable and more importantly does not degrade with the number of users.&lt;/p&gt;&lt;h4&gt;Comparison in a more realistic scenario&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#comparison-in-a-more-realistic-scenario&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;After satisfying our curiosity about Crimeline&amp;#39;s synthetic benchmarks, we were wondering how the comparison would look like in a more realistic scenario.
Therefore, we added another benchmark &lt;code&gt;feeds&lt;/code&gt; that is somewhere in between Crimeline&amp;#39;s benchmarks and the work we&amp;#39;ve previously done with our simulator.
Interestingly, in this more realistic scenario the performance difference between the implementations diminishes.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of follows)&lt;/th&gt;&lt;th&gt;Rust feed&lt;/th&gt;&lt;th&gt;ETS feed&lt;/th&gt;&lt;th&gt;Roaring Bitmaps feed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;get_feed (100)&lt;/td&gt;&lt;td&gt;41 µs&lt;/td&gt;&lt;td&gt;9 μs&lt;/td&gt;&lt;td&gt;8 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;get_feed (100_000)&lt;/td&gt;&lt;td&gt;57 µs&lt;/td&gt;&lt;td&gt;117 μs&lt;/td&gt;&lt;td&gt;108 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;get_feed (1_000_000)&lt;/td&gt;&lt;td&gt;58 µs&lt;/td&gt;&lt;td&gt;118 μs&lt;/td&gt;&lt;td&gt;109 μs&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;Where to go from here?&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#where-to-go-from-here&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We showed how to build a basic dataplane implementation in Elixir, then added optimizations to squeeze out the issues we encountered.&lt;/p&gt;&lt;p&gt;One essential feature that is missing from our implementation is persistence.
So far we store everything in memory, so our data wouldn&amp;#39;t survive a restart of the server.&lt;/p&gt;&lt;p&gt;Our goal is to provide a simple to use dataplane implementation.
So, the first idea would be to add persistence with Postgres and treat our in-memory data as an index to avoid costly queries and retrieve the data directly by primary key.
Another idea is to provide an adapter interface so that everyone can use their preferred database.&lt;/p&gt;&lt;p&gt;We&amp;#39;ve seen how to combine Rust and Elixir to get the strengths of both technologies while mitigating their weaknesses, Discord has another &lt;a href=&quot;https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users&quot;&gt;interesting article&lt;/a&gt; on that topic.&lt;br/&gt;In essence, we can get the high level of abstraction from Elixir which leads to readable, maintainable code and a high velocity.
We can also get the parallelism and robustness of the Erlang VM on which Elixir runs.
In combination with Rust, we can improve our performance both in speed and memory usage while we are confident that the native code won&amp;#39;t crash the VM as we build on Rust&amp;#39;s memory-safety features.
This way, we can optimize the parts of the codebase where we benefit from it.
Whether it&amp;#39;s Roaring Bitmaps or even wrapping the Crimeline datastructures, a whole ecosystem of crates is available to us.&lt;/p&gt;&lt;p&gt;With that, we have all the tools to build the remaining features a complete dataplane implementation needs and we are ready to add more advanced features like the &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;social proof&lt;/a&gt; that is available in Bluesky.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Make Friends With Your AI Assistant</title>
<link>https://rocket-science.ru/hacking/2026/05/18/make-claude-robust</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">JMW_giP8FEk1my0-Dp3QtGo1x6rwIsTFl4NlcQ==</guid>
<pubDate>Mon, 18 May 2026 10:10:08 +0000</pubDate>
<description>Several rules from my experience which would make your cooperation with AI assistants a charm</description>
<content:encoded>&lt;h2&gt;Typography&lt;/h2&gt;&lt;p&gt;AI assistants do apparently love clean markers. I use ⓪ –⑳  markers for numbering, ▸/▹ markers for itemizing, typographically correct quotes (“”/‘’,) and m-dashes. Compose key makes entering that stuff easier, alternative layout makes it a charm. No tips for poor MacOS/Win users, sorry.&lt;/p&gt;&lt;h2&gt;Workflows&lt;/h2&gt;&lt;p&gt;I have system-wide workflows for my assistant. They all look like “START_WORD → WORKFLOW_RULES → STOP_WORD.”&lt;/p&gt;&lt;p&gt;Here is an example:&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;Let’s define the workflow you must follow every time we work on a new task, named NEW_TASK. The rules are following:&lt;/p&gt;

  &lt;p&gt;① The statement “New task &lt;task_name&gt;!&amp;quot; typed by me starts new NEW_TASK workflow&lt;br/&gt;
② Upon starting new workflow you must pull the main branch from github and create a new branch named &amp;quot;&lt;task_name&gt;&amp;quot;&lt;br/&gt;
③ At this point you have to wait for me to enter the problem statement&lt;br/&gt;
④ You have to analyze the problem statement and immediately ask all the questions regarding it where you might have had any uncertancy and/or lack of clarity&lt;br/&gt;
⑤ After everythig is clarified, you must come up with the plan of implementation and ask for its amendment and/or approval&lt;br/&gt;
⑥ Upon approval, you should start implementing it, asking all the questions you might have appeared&lt;br/&gt;
⑦ Once done, you must create a comprehensive tests for the new functionality, documentation in both the source code as standalone (markdown), changelog entry, and a brief what-has-been-done note in markdown&lt;br/&gt;
⑧ After I accept everything by saying &amp;quot;Task done!&amp;quot;, you should commit everything with a descriptive comprehensive commit message, push it to remote, and create a pull request in github.&lt;/task_name&gt;&lt;/task_name&gt;&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;The key insight here is the &lt;strong&gt;stop-words&lt;/strong&gt;. Without them the assistant will cheerfully barrel through all eight steps in one breath, commit untested code to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, and open a pull request titled “feat: everything.” Stop-words are the leash. Love the leash (and the muzzle, your assistant is ill-mannered and disobedient.)&lt;/p&gt;&lt;h2&gt;Context Is King (And Your Assistant Has Amnesia)&lt;/h2&gt;&lt;p&gt;Every new session your assistant is a freshly hatched duckling. It will imprint on whatever you show it first. Feed it your codebase conventions, your style preferences, your testing philosophy—and do it &lt;em&gt;persistently&lt;/em&gt;, through rules and system prompts, not through hopeful repetition in every chat.&lt;/p&gt;&lt;p&gt;The difference between a rule that says “always use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:json&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:jason&lt;/code&gt;” and manually correcting the same dependency in every generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix.exs&lt;/code&gt; is the difference between engineering and penance.&lt;/p&gt;&lt;p&gt;Persistent rules are not optional. They are the civilizational layer between you and the primordial chaos of a context window that has never heard of your project. Each workflow (see above) might have different persistent rules attached.&lt;/p&gt;&lt;h2&gt;Atomic Tasks, or: Stop Writing Novels&lt;/h2&gt;&lt;p&gt;The temptation to say “implement the entire authentication system” is roughly as productive as asking an intern to “make the app work.” Break it down. Then break it down again. If your task description exceeds one screenful, you have written a novel, not a task. If within the task there are forks in the road possible, the assistant will inevitable choose the wrong path.&lt;/p&gt;&lt;p&gt;The assistant will cheerfully &lt;em&gt;attempt&lt;/em&gt; your novel and &lt;em&gt;wild guess&lt;/em&gt; the direction on each fork. It will produce something that compiles, passes zero tests, and makes you question your career choices. Whereas “add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verify_token/2&lt;/code&gt; function that pattern-matches on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:ok, claims}&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:error, reason}&lt;/code&gt;”—that it can do in its sleep. If models slept.&lt;/p&gt;&lt;p&gt;The rule of thumb: &lt;strong&gt;if there is more than one correct architectural path, the task is too large.&lt;/strong&gt; Decide the architecture yourself; delegate the typing.&lt;/p&gt;&lt;h2&gt;Feed It Good Code, Get Good Code&lt;/h2&gt;&lt;p&gt;This is not mysticism, it is pattern matching. If you feed your assistant examples of clean, idiomatic code, it will produce clean, idiomatic code. If you feed it your legacy spaghetti written at 3 AM during a production outage, you will get more spaghetti—but with AI-generated parmesan on top.&lt;/p&gt;&lt;p&gt;I keep a curated set of “golden” modules that I attached as context ages ago. The assistant does not need to be told “you are a senior Elixir architect with 300 years of experience.” It needs to &lt;em&gt;see&lt;/em&gt; what good Elixir looks like. Show, don’t pep-talk.&lt;/p&gt;&lt;h2&gt;Trust, But Verify (Especially the Tests)&lt;/h2&gt;&lt;p&gt;The cruelest irony of AI-assisted development: the assistant generates tests that pass. “Green across the board!” you exclaim, and deploy. Three hours later you discover the tests were testing that the function returns &lt;em&gt;something&lt;/em&gt;, not that it returns the &lt;em&gt;right&lt;/em&gt; something.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Read the generated tests more carefully than the implementation.&lt;/strong&gt; The implementation at least looks suspicious when it is wrong. A bad test looks &lt;em&gt;exactly&lt;/em&gt; like a good test, except it asserts nothing useful. Pattern-match on the actual values; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert result&lt;/code&gt; is not a test, it is a prayer.&lt;/p&gt;&lt;h2&gt;The Art of Saying “No, Try Again”&lt;/h2&gt;&lt;p&gt;Your assistant is not your therapist. It does not need encouragement. “This is wrong” followed by silence teaches it nothing. “This is wrong because the recursive clause does not handle the empty-list base case, see &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List.foldl/3&lt;/code&gt; for the pattern I expect”—now you are getting somewhere. Keep your paw right above &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl&lt;/code&gt;+&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C&lt;/code&gt; combination and press it every time you see the assistant’s thinking process drives bonkers.&lt;/p&gt;&lt;p&gt;You always liked interrupting people in person, feel free to interrupt your deputy.&lt;/p&gt;&lt;h2&gt;Know When to Kill the Session&lt;/h2&gt;&lt;p&gt;If you have been going back and forth for more than three iterations on the same function, close the session. Start fresh. The context window is polluted with failed attempts, corrections, and mutual disappointment—a couples therapy transcript, essentially.&lt;/p&gt;&lt;p&gt;A new session with a clean description of what you &lt;em&gt;actually&lt;/em&gt; want will outperform the seventh round of “no, I meant the &lt;em&gt;other&lt;/em&gt; thing” every single time. Sunk cost fallacy applies to token budgets too.&lt;/p&gt;&lt;p&gt;Please aware, that the necessity to close a session vividly implies your original prompt sucked in the first place. Rewrite it from scratch. Make sure it has no freedom for the assistant to choose paths. They are good at walking the straight roads.&lt;/p&gt;&lt;h2&gt;The Checklist&lt;/h2&gt;&lt;p&gt;Distilled to a grocery list for the impatient:&lt;/p&gt;&lt;p&gt;▸ Use persistent rules, not per-session rituals&lt;br/&gt;
▸ Define workflows with explicit start/stop words&lt;br/&gt;
▸ One architectural decision per task, maximum&lt;br/&gt;
▸ Attach good code as examples, skip the motivational preamble&lt;br/&gt;
▸ Run the full validation pipeline (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;credo&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dialyzer&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;test&lt;/code&gt;) after every step, not at the end&lt;br/&gt;
▸ Read generated tests as if they were written by someone who wants to trick you (they were not, but the effect is the same)&lt;br/&gt;
▸ Three failed iterations = kill the session, rethink the task&lt;br/&gt;
▸ Never let the assistant commit without your explicit say-so&lt;br/&gt;
▸ If you could not solve the problem yourself, the assistant cannot solve it &lt;em&gt;for&lt;/em&gt; you—it can only type faster&lt;/p&gt;&lt;p&gt;The last point deserves a tattoo. An AI assistant is a force multiplier, not a force. Multiply zero by anything you like.&lt;/p&gt;&lt;p&gt;Happy prompting.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Mocks Are Your Friends, Not Your Servants</title>
<link>https://rocket-science.ru/hacking/2026/05/17/mocks-are-your-friends</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">24nWcNfiDTqnapHrTllRG-SjwLMwHSxIpCUCdA==</guid>
<pubDate>Sun, 17 May 2026 18:03:59 +0000</pubDate>
<description>On treating mocks as protagonists of the testing narrative rather than disposable stand-ins hired to nod along</description>
<content:encoded>&lt;p&gt;There is a peculiar tradition in our industry: the moment someone says “mock,” half the room recoils as if you have just proposed replacing the database with a spreadsheet. The other half nods enthusiastically, having already replaced the database with a spreadsheet.&lt;/p&gt;&lt;p&gt;Both camps are wrong, and for the same reason: they think of “mock” as a verb.&lt;/p&gt;&lt;h2&gt;Mock Is a Noun&lt;/h2&gt;&lt;p&gt;José Valim wrote a &lt;a href=&quot;https://dashbit.co/blog/mocks-and-explicit-contracts&quot;&gt;splendid piece on the subject&lt;/a&gt; some years ago, and yet the industry continues to collectively ignore its central thesis with the dedication of a cat ignoring an expensive toy. The key insight is disarmingly simple: &lt;strong&gt;consider “mock” to be a noun, never a verb.&lt;/strong&gt; You do not “mock a module.” You create a mock—an entity, a collaborator, a contract participant—that implements a well-defined behaviour.&lt;/p&gt;&lt;p&gt;The difference is not cosmetic. When you “mock” a function (verb), you are lying to your test. You are saying: “pretend this hole in the wall is a door.” When you create a mock (noun), you are building an actual door—one that opens and closes according to the same interface contract as the production door, just perhaps into a smaller room.&lt;/p&gt;&lt;h2&gt;Stubs Are for Candles, Not for Software&lt;/h2&gt;&lt;p&gt;The prevailing use of mocks in the wild amounts to: “I need this function to return 42 so my other function does not crash.” This is not testing. This is bribery. You have paid off a witness to give the testimony you wanted, and now you are surprised when the real trial goes sideways.&lt;/p&gt;&lt;p&gt;A mock that merely returns canned responses is a stub wearing a fake moustache. It tells you nothing about whether your code actually &lt;em&gt;interoperates&lt;/em&gt; with the expected behaviour. It only tells you that, given a universe where everything behaves exactly as you imagined, your code does not crash. Congratulations—that was never the hard part.&lt;/p&gt;&lt;p&gt;The hard part is the contract. Does your code send the right messages? Does it handle the responses defined by the behaviour? Does it &lt;em&gt;respect the protocol&lt;/em&gt;, not just survive it? A proper mock enforces these questions. A stub sweeps them under the carpet and charges you for the cleaning.&lt;/p&gt;&lt;h2&gt;Promote Your Mocks to Lead Actors&lt;/h2&gt;&lt;p&gt;Here is the heresy: mocks should not sit quietly in the corner of your test, responding when spoken to like well-trained waitstaff. They should be protagonists. They should &lt;em&gt;drive&lt;/em&gt; the testing narrative.&lt;/p&gt;&lt;p&gt;Consider a finite state machine. It transitions through states, fires callbacks, notifies listeners. In production, the listener might persist data, send emails, ring bells. In tests, you do not care about the bells. You care about: did the FSM reach state X with payload Y after event Z?&lt;/p&gt;&lt;p&gt;A mock-as-protagonist answers this question directly. It is not merely absorbing calls—it is &lt;em&gt;reporting back&lt;/em&gt; to the test, asserting that the system did what it promised. The mock becomes your embedded journalist, filing reports from inside the process under test.&lt;/p&gt;&lt;p&gt;This is a fundamentally different posture. The mock is no longer a shift worker you hire to stand in for the real employee. It is a first-class participant in the test, with its own responsibilities, its own assertions, its own voice.&lt;/p&gt;&lt;h2&gt;OTP, Race Conditions, and the Debugger You Deserve&lt;/h2&gt;&lt;p&gt;Anyone who has tested concurrent OTP systems knows the special joy of a test that passes ninety-nine times and fails on the hundredth, always at random time, always on CI, never on your machine. The core problem is structural: you fire an asynchronous message and then try to assert about a state that may or may not have been reached yet. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep(200)&lt;/code&gt; is not a solution—it is a prayer in milliseconds.&lt;/p&gt;&lt;p&gt;Mocks offer something better. When a mock is registered as a listener, every state transition sends a message back to the test process. You can then use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive&lt;/code&gt; to &lt;em&gt;wait&lt;/em&gt; for the transition, with a timeout, deterministically. No sleep. No prayer. No coin flip.&lt;/p&gt;&lt;p&gt;In effect, the mock becomes a breakpoint in a debugger you never had to open. It declares: “I expect to be called with &lt;em&gt;these&lt;/em&gt; arguments, in &lt;em&gt;this&lt;/em&gt; order,” and it sends a message to the test process confirming each call. You get both the expectation (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expect/3&lt;/code&gt;) and the synchronization (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive/2&lt;/code&gt;) in one mechanism. The mock is simultaneously the probe and the signal.&lt;/p&gt;&lt;p&gt;This is not theoretical. In OTP, where processes communicate via message passing and the scheduler is free to interleave execution in whatever order amuses it most, this pattern transforms flaky tests into deterministic ones. You are no longer guessing when the process reached the desired state. You are being &lt;em&gt;told&lt;/em&gt;.&lt;/p&gt;&lt;h2&gt;Listeners, Visibility, and the Joy of Seeing What Happens&lt;/h2&gt;&lt;p&gt;There is a secondary benefit that deserves its own paragraph: visibility.&lt;/p&gt;&lt;p&gt;When you plug a mock in as a listener—not as a replacement for an implementation, but as an &lt;em&gt;observer&lt;/em&gt; of one—the test code becomes radically more readable. Each assertion is a statement about what the system &lt;em&gt;did&lt;/em&gt;, not about what you had to simulate. The reader does not need to mentally reconstruct the production flow from a pile of stubs. The flow is right there, reported by the mock, step by step.&lt;/p&gt;&lt;p&gt;Test code that reads like a narrative of actual system behaviour is test code that people trust. And test code that people trust is test code that people maintain. And test code that people maintain is test code that catches bugs. The chain of causation is longer than a Dickensian sentence, but every link holds.&lt;/p&gt;&lt;h2&gt;Finitomata: A Case Study in Mock-Driven Testing&lt;/h2&gt;&lt;p&gt;All of this is not armchair philosophy. The &lt;a href=&quot;https://github.com/am-kantox/finitomata/blob/main/lib/finitomata/test/ex_unit.ex&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata.ExUnit&lt;/code&gt;&lt;/a&gt; module is a working testing framework built entirely on this premise. It uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mox&lt;/code&gt; not as a crutch but as the foundation.&lt;/p&gt;&lt;p&gt;The setup declares a mock listener. The mock is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allow&lt;/code&gt;-ed to the FSM process and given &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expect&lt;/code&gt;ations for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;after_transition/3&lt;/code&gt; callbacks. Each expectation sends &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:on_transition, id, state, payload}&lt;/code&gt; back to the test process. The test then walks the FSM through its transitions and asserts each state deterministically via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The result looks like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;test_path &amp;quot;respectful passenger&amp;quot;, %{passengers: initial_passengers} do
  :coin_in -&amp;gt;
    assert_state :opened do
      assert_payload do
        data.passengers ~&amp;gt; ^initial_passengers
      end
    end

  :walk_in -&amp;gt;
    assert_state :closed do
      assert_payload do
        data.passengers ~&amp;gt; one_more when one_more == 1 + data.passengers
      end
    end

  :switch_off -&amp;gt;
    assert_state :switched_off
    assert_state :*
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep&lt;/code&gt;. No polling. No race conditions. Each transition is confirmed by the mock-listener before the test proceeds. And crucially: &lt;em&gt;intermediate&lt;/em&gt; states—including those triggered by automatic (bang!) transitions that the test never explicitly fires—are observable and assertable, because the mock reports every single one.&lt;/p&gt;&lt;p&gt;The mock here is not a stand-in. It is the entire testing infrastructure. Remove it, and you are back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep(200)&lt;/code&gt; and crossed fingers.&lt;/p&gt;&lt;h2&gt;The Moral&lt;/h2&gt;&lt;p&gt;Mocks have suffered from a branding problem. They were introduced to the mainstream as “a way to avoid calling the real thing,” which is roughly equivalent to introducing a violin as “a way to avoid silence.” Technically correct, but missing the point so thoroughly that it constitutes its own genre of wrongness.&lt;/p&gt;&lt;p&gt;A mock is a collaborator. A contract enforcer. A synchronisation primitive. A visibility layer. A debugger breakpoint. A protagonist.&lt;/p&gt;&lt;p&gt;Treat it accordingly, and your tests will repay you with the one thing no amount of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep&lt;/code&gt; can buy: confidence.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Passkey Sign In with Elixir and Phoenix</title>
<link>https://shadowfacts.net/2023/phoenix-passkeys/</link>
<enclosure type="image/jpeg" length="0" url="https://shadowfacts.net/shadowfacts.png"></enclosure>
<guid isPermaLink="false">VczlMu0F-895eAWwo-kF-ZStbBe9ejiafL56TQ==</guid>
<pubDate>Fri, 08 May 2026 09:29:25 +0000</pubDate>
<description>The outer part of a shadow is called the penumbra.</description>
<content:encoded>&lt;p&gt;Passkeys are a replacement for passwords that use public/private cryptographic key pairs for login in a way that can be more user-friendly and resistant to a number of kinds of attacks. Let’s implement account registration and login with passkeys in a simple Phoenix web app.&lt;/p&gt;&lt;p&gt;This work is heavily based on &lt;a href=&quot;https://www.imperialviolet.org/2022/09/22/passkeys.html&quot;&gt;this article&lt;/a&gt; by Adam Langley, which provides a great deal of information about implementing passkeys. My goal here is to fill in some more of the details, and provide some Elixir-specific information. As with Adam’s article, I’m not going to use any WebAuthn libraries (even though that may be advisable from a security/maintenance perspective) since I think it’s interesting and helpful to understand how things actually work.&lt;/p&gt;&lt;p&gt;Providing an exhaustive, production-ready implementation is a non-goal of this post. I’m going to make some slightly odd decisions for pedagogical reasons, and leave some things incomplete. That said, I’ll try to note when I’m doing so.&lt;/p&gt;&lt;p&gt;To start, I’m using the default Phoenix template app (less Tailwind) in which I’ve also generated the default, controller-based &lt;a href=&quot;https://hexdocs.pm/phoenix/mix_phx_gen_auth.html&quot;&gt;authentication system&lt;/a&gt;. Some parts of the password-specific stuff have been stripped out altogether, others will get changed to fit with the passkey authentication setup we’re going to build.&lt;/p&gt;&lt;h2&gt;Database schema §&lt;/h2&gt;&lt;p&gt;The first thing we’ll need is a backend schema for passkeys. The only data we need to store for a particular passkey is a unique identifier, and the public key itself. We also want users to be able to have multiple passkeys associated with their account (since they may want to login from multiple platforms that don’t cross-sync passkeys), so the users schema will have a one-to-many relationship with the passkeys.&lt;/p&gt;&lt;p&gt;The actual model I’ll call &lt;code&gt;UserCredential&lt;/code&gt;, since that’s closer to what the WebAuthn spec calls them. Here’s the schema, it’s pretty simple:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts.UserCredential do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary, []}

  schema &amp;quot;users_credentials&amp;quot; do
    # DER-encoded Subject Public Key Info: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7
    field :public_key_spki, :binary
    belongs_to :user, PhoenixPasskeys.Accounts.User
    timestamps()
  end

  def changeset(credential, attrs) do
    credential
    |&amp;gt; cast(attrs, [:id, :public_key_spki, :user_id])
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There are a couple things to note here:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;First, we’re explicitly specifying that the primary key is a binary, since that’s what the WebAuthn API provides as the credential ID.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;public_key_spki&lt;/code&gt; field contains the data for the public key itself and the algorithm being used. We don’t care about the specific format, though, since we don’t have to parse it ourselves.&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;To the generated &lt;code&gt;User&lt;/code&gt; schema, I also added the &lt;code&gt;has_many&lt;/code&gt; side of the relationship. There’s also a migration to create the credentials table. I won’t show it here, since it’s exactly what you’d expect—just make sure the &lt;code&gt;id&lt;/code&gt; column is a binary.&lt;/p&gt;&lt;h2&gt;Registration JavaScript §&lt;/h2&gt;&lt;p&gt;WebAuthn, being a modern web API, is a JavaScript API. This is a distinct disadvantage, if your users expect to be able to login from JavaScript-less browsers. Nonetheless, we proceed with the JS. Here’s the first bit:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    const registrationForm = document.getElementById(&amp;quot;registration-form&amp;quot;);
    if (registrationForm) {
        registrationForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
            event.preventDefault();
            registerWebAuthnAccount(registrationForm);
        });
    }
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If we find the registration &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element (note that I’ve added an ID (and also removed the password field)), we add a submit listener to it which prevents the form submission and instead calls the &lt;code&gt;registerWebAuthnAccount&lt;/code&gt; function we’ll create next.&lt;/p&gt;&lt;p&gt;Before we get there, we’ll also write a brief helper function to check whether passkeys are actually available:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function supportsPasskeys() {
    if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
		return false;
	}
	const [conditional, userVerifiying] = await Promise.all([
		PublicKeyCredential.isConditionalMediationAvailable(),
		PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
	]);
	return conditional &amp;amp;&amp;amp; userVerifiying;
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The conditional mediation check establishes that WebAuthn conditional UI is available. This is what we’ll use for login, and is part of what makes using passkeys a nice, slick experience. Conditional UI will let us start a request for a passkey that doesn’t present anything until the user interacts with the browser’s passkey autofill UI.&lt;/p&gt;&lt;p&gt;The user-verifying platform authenticator check establishes that, well, there is a user-verifying platform authenticator. The platform authenticator part means an authenticator that’s part of the user’s device, not removable, and the user-verifying part means that the authenticator verifies the presence of the user (such as via biometrics).&lt;/p&gt;&lt;p&gt;In the &lt;code&gt;registerWebAuthnAccount&lt;/code&gt; function, the first thing we need to do is check that both these conditions are met and passkeys are supported by the browser. If not, we’ll just bail out and registration won’t be possible.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    if (!(await supportsPasskeys())) {
        return;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next, we’ll setup a big ol’ options object that we’ll pass to the WebAuthn API to specify what sort of credential we want. There’s going to be a whole lot of stuff here, so lets take it one piece at a time.&lt;/p&gt;&lt;p&gt;The RP is the Relying Party—that is, us, the party which relies on the credentials for authentication. The ID is the &lt;a href=&quot;https://w3c.github.io/webauthn/#rp-id&quot;&gt;domain of the RP&lt;/a&gt;—just localhost for this example, though in reality you’d need to use your actual domain in production. The name is just a user-facing name for the RP.&lt;/p&gt;&lt;p&gt;Next up is some info about the user who the credential is for. The user’s “name” will just be the email that they entered in the form. The &lt;code&gt;displayName&lt;/code&gt; value is required, per the spec, but we don’t have any other information so we just leave it blank and let the browser display the &lt;code&gt;name&lt;/code&gt; only. The ID is where this gets a little weird, since we’re just generating a random 64-byte value:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;function generateUserID() {
	const userID = new Uint8Array(64);
	crypto.getRandomValues(userID);
	return userID;
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you were adding a passkey to an existing user account, you could use a server-specified value for the user ID and the browser would replace any existing credentials with the same user ID and RP ID. But, since the user is registering a new account at this point, we assume they don’t want to do that (and, moreover, we don’t have any ID we can use yet). The user ID will also be returned upon a successful login, which would let us look up the user that’s logging in. However, since we’re only going to allow a credential to belong to a single user, we can use the credential’s ID to uniquely determine the user.&lt;/p&gt;&lt;p&gt;Next up, we specify the types of public keys that we’ll accept. We’re going to support Ed25519, ES256, and RS256, since those are recommended by the WebAuthn spec:&lt;/p&gt;&lt;p&gt;During the login process, the server will generate a challenge value that the client will sign and the server will verify was signed with the user’s private key. The spec also requires that we provide a challenge when creating a credential, but we’re not going to use it for anything (since the user is just now creating the credential, we have nothing trusted that we can verify the initial challenge against), so we just provide an empty value:&lt;/p&gt;&lt;p&gt;Lastly, we give our requirements for authenticators that we want to use:&lt;/p&gt;&lt;p&gt;We want only the platform authenticator, not anything else, like removable security keys. We also specify that we want a resident key. This usage of “resident” is deprecated terminology that’s enshrined in the API. Really it means we want a &lt;em&gt;discoverable&lt;/em&gt; credential, so that the authenticator will surface it during the login process without us having to request the credential by ID. This is important to note, since it’s what prevents needing a separate username entry step.&lt;/p&gt;&lt;p&gt;Now that we (finally) have all the configuration options in place, we can actually proceed with the credential creation. We pass the options to &lt;code&gt;navigator.credentials.create&lt;/code&gt; to actually create the WebAuthn credential. If that fails, we’ll just take the easy way out and alert to inform the user (in an actual service, you’d probably want better error handling).&lt;/p&gt;&lt;p&gt;From the credential we get back, we need a few pieces of information. First is the decoded client data, which is an object that contains information about the credential creation request that occurred.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;clientDataJSON&lt;/code&gt; field of the response object contains the JSON-serialized object in an &lt;code&gt;ArrayBuffer&lt;/code&gt;, so we decode that to text and then parse the JSON. With the decoded object, we do a consistency check with a couple pieces of data: the type of the request, whether or not it was cross-origin, and the actual origin being used.&lt;/p&gt;&lt;p&gt;If it was not a creation request, the request was cross-origin, or the origin doesn’t match, we bail out. Note that in production, the origin should be checked against the actual production origin, not localhost. And again, in reality you’d want better error handling than just an alert.&lt;/p&gt;&lt;p&gt;Next, we need to get the authenticator data which is encoded in a binary format and pull a few pieces of data out of it. You can see the full format of the authenticator data &lt;a href=&quot;https://w3c.github.io/webauthn/#sctn-authenticator-data&quot;&gt;in the spec&lt;/a&gt;, but the parts we’re interested in are the backed-up state and the credential ID, which is part of the &lt;a href=&quot;https://w3c.github.io/webauthn/#attested-credential-data&quot;&gt;attested credential data&lt;/a&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const authenticatorData = new Uint8Array(credential.response.getAuthenticatorData());
    const backedUp = (authenticatorData[32] &amp;gt;&amp;gt; 4) &amp;amp; 1;
    const idLength = (authenticatorData[53] &amp;lt;&amp;lt; 8) | authenticatorData[54];
    const id = authenticatorData.slice(55, 55 + idLength);
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We get the backed-up bit from the flags byte at offset 32. Then we get the length of the credential ID, which is encoded as a big-endian, 16-bit integer at bytes 53 and 54. The ID itself immediately follows the length, thus starting at byte 55.&lt;/p&gt;&lt;p&gt;Before proceeding, we check that the &lt;a href=&quot;https://w3c.github.io/webauthn/#sctn-credential-backup&quot;&gt;backed-up bit&lt;/a&gt; is set, indicating that the credential is backed-up and safe from the user losing the current device. If it’s not, we won’t let the user register with this passkey. I choose to do this since it’s recommended by Adam Langley’s blog post, but whether it’s actually necessary may depend on your specific circumstances.&lt;/p&gt;&lt;p&gt;The last piece of data we need out of the credential is the actual public key:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const publicKey = credential.response.getPublicKey();
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And with all that in place, we can actually initiate the registration. We’ll assemble a form data payload with all of the requisite values:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const body = new FormData();
    body.append(&amp;quot;_csrf_token&amp;quot;, form._csrf_token.value);
    body.append(&amp;quot;email&amp;quot;, form[&amp;quot;user[email]&amp;quot;].value);
    body.append(&amp;quot;credential_id&amp;quot;, arrayBufferToBase64(id));
    body.append(&amp;quot;public_key_spki&amp;quot;, arrayBufferToBase64(publicKey));
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We’ll use the body in a POST request to the registration endpoint. The response we get back will contain a status value to indicate whether the request was successful or not. If it was successful, the backend will have set the session cookie, and we can redirect to the home page and the new user will be logged in. If registration failed, we’ll alert the user.&lt;/p&gt;&lt;p&gt;And with that, we’re done with JavaScript (for now) and can move on to the backend part of registering an account.&lt;/p&gt;&lt;h2&gt;Creating a user §&lt;/h2&gt;&lt;p&gt;In the &lt;code&gt;UserRegistrationController&lt;/code&gt; module that comes with the Phoenix auth template, we’ll change the &lt;code&gt;create&lt;/code&gt; function. By default, it registers the user using the parameters from the signup form and then redirects to the homepage. Instead, we’re going to register using the passkey we created on the client and then respond with the JSON that our JavaScript is expecting.&lt;/p&gt;&lt;p&gt;The first thing we need to do is extract the values that were sent by the frontend and decode the base 64-encoded ones.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(conn, %{
        &amp;quot;email&amp;quot; =&amp;gt; email,
        &amp;quot;credential_id&amp;quot; =&amp;gt; credential_id,
        &amp;quot;public_key_spki&amp;quot; =&amp;gt; public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Those get passed to the &lt;code&gt;Accounts.register_user&lt;/code&gt; function, which we’ll update shortly to handle. If account creation succeeded, we’ll still send the confirmation email as the existing code did. After that, instead of redirecting, we’ll log the user in by setting the session cookie and then respond with the “ok” status for the frontend. If account creation fails, we’ll just respond with the “error” status so the frontend can alert the user.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(...) do
    # ...
    case Accounts.register_user(email, credential_id, public_key_spki) do
      {:ok, user} -&amp;gt;
        # Send confirmation email...

        conn
        |&amp;gt; UserAuth.log_in_user_without_redirect(user)
        |&amp;gt; json(%{status: :ok})

      {:error, _changeset} -&amp;gt;
        json(conn, %{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Let’s update the &lt;code&gt;register_user&lt;/code&gt; function. Instead of just creating a changeset for the user and then inserting it, we need to also create the authenticator. To avoid potentially leaving things in a broken state, we wrap both of these in a database transaction.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn -&amp;gt;
      user =
        %User{}
        |&amp;gt; User.registration_changeset(%{email: email})
        |&amp;gt; Repo.insert()
        |&amp;gt; case do
          {:ok, user} -&amp;gt; user
          {:error, changeset} -&amp;gt; Repo.rollback(changeset)
        end
    end)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;First, we create a user with the given email. If the user creation fails, we abort and rollback the transaction. Then, we can create a credential belonging to the new user with the credential ID and public key we received from the client. As with the user, if creating the credential fails, we rollback the transaction.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn -&amp;gt;
      # ...
      %UserCredential{}
      |&amp;gt; UserCredential.changeset(%{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
      |&amp;gt; Repo.insert()
      |&amp;gt; case do
        {:ok, _credential} -&amp;gt; nil
        {:error, changeset} -&amp;gt; Repo.rollback(changeset)
      end
    end)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We don’t need to do anything with the newly created credential, so we can just ignore it once it’s been created.&lt;/p&gt;&lt;p&gt;And finally, from the transaction function, we return the user:&lt;/p&gt;&lt;p&gt;The last thing we need to to complete the registration flow is to set the new user’s session cookie so that they’re logged in immediately. The &lt;code&gt;UserAuth&lt;/code&gt; module that’s generated as part of the Phoenix auth template has a &lt;code&gt;log_in_user&lt;/code&gt; function that does exactly this. But it also redirects the connection to another endpoint. We don’t want to do that, since we’re sending a JSON response, so I’ve split the function into two: one that only sets the session, and the existing function that sets the session and then redirects.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserAuth do
  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)
    conn
    |&amp;gt; log_in_user_without_redirect(user, params)
    |&amp;gt; redirect(to: user_return_to || signed_in_path(conn))
  end

  def log_in_user_without_redirect(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
    conn
    |&amp;gt; renew_session()
    |&amp;gt; put_token_in_session(token)
    |&amp;gt; maybe_write_remember_me_cookie(token, params)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And with that, everything is in place and you can now create an account with a passkey!&lt;/p&gt;&lt;h2&gt;Login form §&lt;/h2&gt;&lt;p&gt;Now that the user’s got an account, they need to be able to login with it. That means once again interacting with the WebAuthn API and writing a bunch of JavaScript. But before we get there, we need some slight changes to the backend.&lt;/p&gt;&lt;p&gt;In the HTML for the login page, we’ll add an ID to the &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element so that we can find it from JS. We’ll also remove the password field, which is obviously no longer necessary. Lastly, but certainly not least, we need to send a challenge to the client.&lt;/p&gt;&lt;p&gt;The challenge is a value that the user’s device will cryptographically sign with their private key. The result will get sent back to the server, and we’ll verify it against the public key we have stored thus authenticating them. We’ll send the challenge just in a hidden form field:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&amp;lt;input type=&amp;quot;hidden&amp;quot; id=&amp;quot;challenge&amp;quot; value={@webauthn_challenge} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the session controller, we’ll need to generate and assign the challenge to the connection.&lt;/p&gt;&lt;p&gt;WebAuthn expects the challenge to be a value up to 64 bytes long, so we’ll use the Erlang crypto module to generate one of that length. The value is encoded as &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc4648#section-5&quot;&gt;URL-safe base 64&lt;/a&gt; (the same as normal base 64, but with dash and underscore rather than plus and slash) without padding. We encode it this way since that’s the format in which it will later be &lt;a href=&quot;https://w3c.github.io/webauthn/#dom-collectedclientdata-challenge&quot;&gt;returned&lt;/a&gt; as part of the &lt;code&gt;clientDataJSON&lt;/code&gt;, so when we extract that value we can directly compare it to the challenge value we generated.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  defp put_webauthn_challenge(conn) do
    challenge =
      :crypto.strong_rand_bytes(64)
      |&amp;gt; Base.url_encode64(padding: false)

    conn
    |&amp;gt; put_session(:webauthn_challenge, challenge)
    |&amp;gt; assign(:webauthn_challenge, challenge)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Note that the challenge string is also stored in the session, so that we can later check that the challenge that the client signed matches the challenge we generated. It’s safe to store this in the session, even though it’s sent to the client, because the cookie is encrypted and signed so the client can’t tamper with it.&lt;/p&gt;&lt;h2&gt;Login JavaScript §&lt;/h2&gt;&lt;p&gt;With the first part of the backend changes taken care of, it’s time for more JavaScript, baby!&lt;/p&gt;&lt;p&gt;We’ll follow a similar outline to the registration setup (and the same caveat applies about error handling).&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
    const loginForm = document.getElementById(&amp;quot;login-form&amp;quot;);
	if (loginForm) {
        loginWebAuthnAccount(loginForm);
    }
});

async function loginWebAuthnAccount(loginForm) {
    if (!(await supportsPasskeys())) {
        return;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first thing we need to do is grab the challenge from the hidden form field, then we can construct the options object for getting the credential (which is, thankfully, much simpler than for creation).&lt;/p&gt;&lt;p&gt;In the options, we specify that we want conditional mediation. As noted before, this means that the browser won’t display any UI, except for autofill, for this credential request until the user accepts the autofill suggestion. In the public key options, we also give the decoded challenge value and specify the our Relying Party ID (again, this would need to be the actual domain in production).&lt;/p&gt;&lt;p&gt;Now, we can actually make the credential request and then, if we get a credential back, encode and send all the values to the backend. We need to send the ID of the credential, so that the backend can find its public key and the corresponding user. We also need the client data JSON, which we send as text decoded from the &lt;code&gt;ArrayBuffer&lt;/code&gt; it’s returned as. We also need to send the authenticator data as well as the signature itself.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function loginWebAuthnAccount(loginForm) {
    // ...
    const credential = await navigator.credentials.get(getOptions);
    if (!credential) {
        alert(&amp;quot;Could not get credential&amp;quot;);
        return;
    }

    const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);

    const body = new FormData();
    body.append(&amp;quot;_csrf_token&amp;quot;, loginForm._csrf_token.value);
    body.append(&amp;quot;raw_id&amp;quot;, arrayBufferToBase64(credential.rawId));
    body.append(&amp;quot;client_data_json&amp;quot;, clientDataJSON);
    body.append(&amp;quot;authenticator_data&amp;quot;, arrayBufferToBase64(credential.response.authenticatorData));
    body.append(&amp;quot;signature&amp;quot;, arrayBufferToBase64(credential.response.signature));
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can then send a request to the login endpoint. If the backend request fails (or if we failed to get the credential) we just alert the user (again, you’d probably want something better in reality). If the login attempt was successful, the server will have set the session cookie, and so we can just redirect to the homepage and the user will be logged in.&lt;/p&gt;&lt;p&gt;With that in place, let’s move on to the backend half of the login request.&lt;/p&gt;&lt;h2&gt;Validating a login attempt §&lt;/h2&gt;&lt;p&gt;As with signup, we’ll modify the existing log in endpoint to actually validate the WebAuthn login attempt.&lt;/p&gt;&lt;p&gt;The first step is extracting all of the information the frontend provides in the params and decoding it:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    id = params |&amp;gt; Map.get(&amp;quot;raw_id&amp;quot;) |&amp;gt; Base.decode64!()
    authenticator_data = params |&amp;gt; Map.get(&amp;quot;authenticator_data&amp;quot;) |&amp;gt; Base.decode64!()
    client_data_json_str = params |&amp;gt; Map.get(&amp;quot;client_data_json&amp;quot;)
    signature = params |&amp;gt; Map.get(&amp;quot;signature&amp;quot;) |&amp;gt; Base.decode64!()
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next, we’re going to validate all of the information we got from the client. Before we get to that, there are a handful of helper functions we’ll use. First, looking up a credential by its ID:&lt;/p&gt;&lt;p&gt;Next, a function in the controller that verifies the signature against the provided data:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  defp verify_signature(credential, client_data_json_str, authenticator_data, signature) do
    with {:ok, pubkey} &amp;lt;- X509.PublicKey.from_der(credential.public_key_spki),
         client_data_json_hash &amp;lt;- :crypto.hash(:sha256, client_data_json_str),
         signed_message &amp;lt;- authenticator_data &amp;lt;&amp;gt; client_data_json_hash,
         true &amp;lt;- :public_key.verify(signed_message, :sha256, signature, pubkey) do
      true
    else
      _ -&amp;gt;
        false
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;X509&lt;/code&gt; comes from the &lt;a href=&quot;https://hex.pm/packages/x509&quot;&gt;&lt;code&gt;x509&lt;/code&gt;&lt;/a&gt; package, which is the only third-party piece of code we’re using. It’s a fairly thin wrapper around the Erlang &lt;code&gt;public_key&lt;/code&gt; and &lt;code&gt;crypto&lt;/code&gt; modules, and mostly serves to save me from having to deal with Erlang records in my code. Its &lt;code&gt;from_der&lt;/code&gt; helper function is used to parse the public key from the encoded format.&lt;/p&gt;&lt;p&gt;Next, we hash the client data JSON and append that hash to the authenticator data from the client. This value is what should match the signature using the public key we’ve got, so finally we check that. If all these steps succeeded, we return true, and false otherwise.&lt;/p&gt;&lt;p&gt;The last helper function will receive the decoded client data make sure it’s got all of the values that we expect. If the &lt;code&gt;crossOrigin&lt;/code&gt; value is present and is not false, the client data is invalid and the login attempt will be rejected.&lt;/p&gt;&lt;p&gt;Otherwise, we check that the data has the expected type and origin, and we extract the challenge value (note that we’re checking the origin again here, and this would need to change in production):&lt;/p&gt;&lt;p&gt;And lastly, if neither of the previous patterns matched, the client data fails validation:&lt;/p&gt;&lt;p&gt;Now, let’s put all those parts together and validate the login attempt.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    # ...
    with credential when not is_nil(credential) &amp;lt;- Accounts.get_credential(id),
         true &amp;lt;- verify_signature(credential, client_data_json_str, authenticator_data, signature),
         {:ok, client_data_json} &amp;lt;- Jason.decode(client_data_json_str),
         {:ok, challenge} &amp;lt;- check_client_data_json(client_data_json),
         true &amp;lt;- challenge == get_session(conn, :webauthn_challenge),
         true &amp;lt;- :binary.part(authenticator_data, 0, 32) == :crypto.hash(:sha256, &amp;quot;localhost&amp;quot;),
         true &amp;lt;- (:binary.at(authenticator_data, 32) &amp;amp;&amp;amp;&amp;amp; 1) == 1 do
      conn
      |&amp;gt; delete_session(:webauthn_challenge)
      |&amp;gt; UserAuth.log_in_user_without_redirect(credential.user)
      |&amp;gt; json(%{status: :ok})
    else
      _ -&amp;gt;
        json(conn, %{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here’s everything that we’re doing:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;Lookup the credential with the given ID.&lt;/li&gt;
&lt;li&gt;Use the public key we had stored to verify the signature on the authenticator data and client data JSON.&lt;/li&gt;
&lt;li&gt;Decode the client data JSON.&lt;/li&gt;
&lt;li&gt;Check that all the values in the client data are what we expect, and extract the challenge that was signed.&lt;/li&gt;
&lt;li&gt;Ensure the challenge that the user signed matches what we previously generated.&lt;/li&gt;
&lt;li&gt;Extract the hash of the origin from the &lt;a href=&quot;https://w3c.github.io/webauthn/#authenticator-data&quot;&gt;authenticator data&lt;/a&gt; and ensure it matches our origin (this would not be localhost in production).&lt;/li&gt;
&lt;li&gt;Check that the authenticator data has the user presence bit set (indicating that a person was actually present on the client’s end).&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;If all of those steps succeeded, we remove the old challenge value from the session (since it’s no longer needed), actually log the user in, and then respond with the “ok” status that the JavaScript is expecting. If any step failed, we’ll respond with the “error” status and the frontend will alert the user.&lt;/p&gt;&lt;p&gt;Since there’s a lot going on here, it’s worth being clear about what exactly in this process lets us authenticate and prove that the user is who they claim to be. Since the signature verification step is using the public key that we stored during the registration process, we know that anyone that can produce a valid signature using that public key must be the user (or at any rate, have their private key). The value that they’re signing is, essentially, the challenge: a securely generated random value. The user isn’t directly signing the challenge, but this is still safe, since the challenge value is included in the client data JSON, that hash of which is included in the signed message.&lt;/p&gt;&lt;p&gt;So: the challenge value that was signed by the user must be in the client data, and the challenge value in the client data must be the one we generated. Given that, we know that the user whose key was used to sign the message is the one trying to log in now. That we’re verifying with the stored public key prevents an attacker from using an arbitrary key to sign the login attempt. And that the signed challenge matches the challenge the server generated means an attacker can’t reuse a previous response to login (a replay attack).&lt;/p&gt;&lt;p&gt;At long last, we finally have the ability to log in to our application using a passkey. Only a few minor things to go, so let’s forge ahead.&lt;/p&gt;&lt;h2&gt;Handling login if the user enters an email §&lt;/h2&gt;&lt;p&gt;Although we’re presenting the conditional UI, there’s nothing preventing the user from typing their email into the field and then clicking “Sign in,” so we should probably handle that to. This can be done fairly simply by reusing our existing code for conditional login.&lt;/p&gt;&lt;p&gt;We’ll change the &lt;code&gt;loginWebAuthnAccount&lt;/code&gt; to take an additional parameter, &lt;code&gt;conditional&lt;/code&gt;, which will be a boolean indicating whether this login attempt is to setup the conditional UI or triggered by submitting the login form.&lt;/p&gt;&lt;p&gt;If it’s false, we won’t request conditional mediation and instead we’ll look up the credentials corresponding to the email the user entered and ask WebAuthn for one of those:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function loginWebAuthnAccount(loginForm, conditional) {
    // ...
    let allowCredentials = [];
    if (!conditional) {
		const email = loginForm[&amp;quot;user[email]&amp;quot;].value;
		const resp = await fetch(`/users/log_in/credentials?email=${email}`);
		const respJSON = await resp.json();
		allowCredentials = respJSON.map((id) =&amp;gt; {
			return {
				type: &amp;quot;public-key&amp;quot;,
				id: base64ToArrayBuffer(id),
			};
		});
    }

    const getOptions = {
		mediation: conditional ? &amp;quot;conditional&amp;quot; : &amp;quot;optional&amp;quot;,
		publicKey: {
			challenge: base64URLToArrayBuffer(challenge),
			rpId: &amp;quot;localhost&amp;quot;,
			allowCredentials,
		}
    };
    // ...
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The “optional” value for the &lt;code&gt;mediation&lt;/code&gt; option means that the authenticator isn’t required to display UI, but will do so if its policies dictate that. The &lt;code&gt;allowCredentials&lt;/code&gt; array contains objects describing all of the credentials that we want to accept—specifically, their binary IDs as &lt;code&gt;ArrayBuffer&lt;/code&gt;s.&lt;/p&gt;&lt;p&gt;We look up the user’s credentials so that the one we actually request from the authenticator matches the account that the user is trying to log in with. To handle this, we’ll also wire up an additional route on the backend that returns the base 64-encoded IDs of all the credentials belonging to the user with a given email.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;get_credentials_by_email&lt;/code&gt; function is quite simple. It just looks up a user by email, preloading any credentials they have and then returning them:&lt;/p&gt;&lt;p&gt;Back in the JS, we can tweak the setup code to pass &lt;code&gt;true&lt;/code&gt; for the conditional parameter in the initial request and also register a submit handler on the login form that will invoke it with &lt;code&gt;false&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
	const loginForm = document.getElementById(&amp;quot;login-form&amp;quot;);
	if (loginForm) {
		loginWebAuthnAccount(loginForm, true);
		loginForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
			event.preventDefault();
			loginWebAuthnAccount(loginForm, false);
		});
	}
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And so we’ve handled the case where the user ignores the conditional UI and still types in their email to log in.&lt;/p&gt;&lt;h2&gt;Passkey reset §&lt;/h2&gt;&lt;p&gt;A not-infrequent argument against passkeys is that if your phone falls into a lake, you lose access to all of your passkey-backed accounts. One could argue that this isn’t true because the definition of passkeys means that they’re backed up and thus protected from this sort of event (and indeed, earlier we only permitted registering with backed-up credentials). I think the argument isn’t particularly interesting, however, because you can still have a “Forgot my passkey” option that works just like it does now with passwords.&lt;/p&gt;&lt;p&gt;This is less secure than a passkey implementation that has no “Forgot” option. But it’s no less secure than current password-based systems, and I think the UX/security tradeoff here falls on the UX side—people will, inevitably, lose access to their passkeys while retaining access to their email.&lt;/p&gt;&lt;p&gt;Implementing isn’t too complicated, fortunately, since we can reuse much of the registration code. First, the JavaScript. The only change necessary is attaching the registration function to the reset form as well.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
	const resetForm = document.getElementById(&amp;quot;reset-passkey-form&amp;quot;);
	if (resetForm) {
		resetForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
			event.preventDefault();
			registerWebAuthnAccount(resetForm);
		});
	}
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the HTML for the reset form, we we need to include the email in the same form field as the registration form (and also add an ID to the &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;user[email]&amp;quot; value={@user.email} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the function for the reset route (which is changed to POST from PUT, to match the signup route), we take the credential ID and public key and use them to update the user’s credentials:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserResetPasswordController do
  def update(conn, %{
        &amp;quot;credential_id&amp;quot; =&amp;gt; credential_id,
        &amp;quot;public_key_spki&amp;quot; =&amp;gt; public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
    case Accounts.reset_user_credentials(conn.assigns.user, credential_id, public_key_spki) do
      :ok -&amp;gt;
        conn
        |&amp;gt; put_flash(:info, &amp;quot;Passkey reset successfully.&amp;quot;)
        |&amp;gt; UserAuth.log_in_user_without_redirect(conn.assigns.user)
        |&amp;gt; json(%{status: :ok})

      {:error, _} -&amp;gt;
        conn
        |&amp;gt; put_flash(:error, &amp;quot;Error resetting passkey&amp;quot;)
        |&amp;gt; json(%{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After updating, we log the user in if applicable and return JSON with the appropriate status.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;reset_user_credentials&lt;/code&gt; function works very similarly to the original reset password function that was part of the template: it deletes all the user’s existing sessions, and then removes their existing credentials and creates a new one:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def reset_user_credentials(user, credential_id, public_key_spki) do
    Ecto.Multi.new()
    |&amp;gt; Ecto.Multi.delete_all(
      :old_credentials,
      from(a in UserCredential, where: a.user_id == ^user.id)
    )
    |&amp;gt; Ecto.Multi.insert(
      :new_credential,
      UserCredential.changeset(%UserCredential{}, %{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
    )
    |&amp;gt; Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
    |&amp;gt; Repo.transaction()
    |&amp;gt; case do
      {:ok, _} -&amp;gt; :ok
      {:error, _, changeset, _} -&amp;gt; {:error, changeset}
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It’s worth noting that this does have slightly weaker security properties than the &lt;code&gt;phx.gen.auth&lt;/code&gt; reset password implementation. With that approach, leaking a password reset token does not necessarily result in an account takeover, since whoever obtained the leaked token may not know the target user’s email address. Since the auth template forces the user to re-login after a reset, this prevents someone without the email address from gaining access even if they change the password.&lt;/p&gt;&lt;p&gt;But since logging in with a passkey is functionally a single factor, resetting it means gaining access to the account. So, a leaked reset token gives the bearer control over the account. This is an argument against having a reset option, but whether this is a concern in practice depends on your specific circumstances.&lt;/p&gt;&lt;h2&gt;Conclusion §&lt;/h2&gt;&lt;p&gt;As noted, this is not a complete implementation. There are a handful of places where I’ve left things unfinished since this isn’t meant to be production-level code. There are also a few places where there are security decisions that need to be made on a more contextual basis, that I’ve tried to note. And, of course, you wouldn’t really want to only permit signing in with passkeys and wholesale drop the passwords column from your database.&lt;/p&gt;&lt;p&gt;Nonetheless, I hope this has been a helpful look at how to implement passkeys in an Elixir/Phoenix application. You can find the complete repo &lt;a href=&quot;https://git.shadowfacts.net/shadowfacts/phoenix_passkeys&quot;&gt;here&lt;/a&gt;, and it may also be useful to look at the &lt;a href=&quot;https://git.shadowfacts.net/shadowfacts/phoenix_passkeys/commit/ce4f485dbc4e528bdd13a57b0d379012dd893338&quot;&gt;specific commit&lt;/a&gt; where passkey support was added.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>From legacy code to verifiable specifications with Surveyor</title>
<link>https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor</link>
<guid isPermaLink="false">OwKRRarDBvCxLjw8oGuHThV0mW_dK7NcEu5NrA==</guid>
<pubDate>Wed, 29 Apr 2026 20:10:39 +0000</pubDate>
<description>Take ownership of code you don&#39;t trust. Surveyor extracts a verifiable behavioral specification of a legacy system. Rewrite with certainty.</description>
<content:encoded>&lt;h2&gt;What is legacy code?&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-is-legacy-code&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Sometimes that&amp;#39;s the fifteen-year-old monolith.
Sometimes it&amp;#39;s the service that grew complicated faster than the team could keep up with.
Sometimes it&amp;#39;s that project that started as a shortcut and turned into a roadblock. Legacy code has been exposed to real world demands long enough to gather fixes for edge cases we are not aware of anymore.&lt;/p&gt;&lt;p&gt;And — increasingly — sometimes it&amp;#39;s the repo your CEO vibe-coded last weekend and that they proudly present on Monday.
It runs. Now you have to own it.&lt;/p&gt;&lt;h2&gt;Legacy software is the code we lost confidence in&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#legacy-software-is-the-code-we-lost-confidence-in&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;That last category is genuinely new and growing fast. Code written once, never reviewed, tested a bit, deployed one day. Or, worse, released as a library and never maintained after that.&lt;/p&gt;&lt;p&gt;A new kind of technical dept has evolved: &lt;strong&gt;&lt;em&gt;&amp;quot;The code we should review thoroughly one day&lt;/em&gt;&lt;/strong&gt;.&amp;quot;&lt;/p&gt;&lt;h2&gt;&amp;quot;Can we rewrite this?&amp;quot;&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#can-we-rewrite-this&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The actual problem is not the writing of the new code, but the discovery of what the old code does, and the certainty that the new one does the same thing.&lt;/p&gt;&lt;p&gt;We&amp;#39;ve been building two small Elixir tools to help with both halves of that problem.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Surveyor&lt;/strong&gt; scans a codebase in any language and produces an architectural model.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Assay&lt;/strong&gt; runs the same plain-text behavioral specs against both the legacy and the rewrite, so you can prove they behave the same.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Together they cover the workflow: Figure out the structure, capture the behavior, rewrite, prove the rewrite works.&lt;/p&gt;&lt;div&gt;&lt;div&gt;TL;DR&lt;/div&gt;&lt;div&gt;&lt;p&gt;Surveyor produces an &lt;strong&gt;automatically verifiable behavioral specification&lt;/strong&gt; of a legacy system.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;How we got here&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-we-got-here&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Before going into either tool, it&amp;#39;s worth walking through the shape of the problem.
The picture builds up step by step, and the final diagram only makes sense once you&amp;#39;ve seen the holes in the earlier ones.&lt;/p&gt;&lt;h3&gt;What we don&amp;#39;t want&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-we-dont-want&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-0-158d6135059c381671508607696828b4.png&quot; alt=&quot;Legacy Codebase with a dashed arrow pointing directly at a Target Codebase, with no intermediate steps&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;A direct rewrite also copies bugs, dead code and unused features.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Point an agent at a legacy codebase, ask it to produce a new one, hope for the best.
Call it &amp;quot;the Claude zombie rewrite.&amp;quot;
You end up with a target codebase that nobody understands either, plus a generation gap between the old assumptions and the new. What has the agent overlooked, misunderstood, assumed?
A black box gets replaced with a different black box.
That is not a rewrite, that is a transcoding.&lt;/p&gt;&lt;h3&gt;What we want instead&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-we-want-instead&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-1-b16e88b3f686956678092c1d93021641.png&quot; alt=&quot;Legacy Codebase with an arrow into a TXT file labelled &amp;amp;quot;Readable Specification&amp;amp;quot;.&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The features of the legacy codebase need to be revised&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The valuable artifact in the middle is not new code — it&amp;#39;s a
&lt;strong&gt;readable specification&lt;/strong&gt; of what the old code &lt;strong&gt;actually does&lt;/strong&gt;. Plain text,
human-reviewable, version-controllable. The kind of thing you need when you
want to ask the product owner if a feature is &lt;strong&gt;really&lt;/strong&gt; wanted that way. - Or if it is just a legacy quirk.&lt;/p&gt;&lt;p&gt;If you have it, you can use it for estimates. It can be your map to plan the
implementation and might even give you a hint about that stakeholder that you
might have forgotten about otherwise.&lt;/p&gt;&lt;h3&gt;Organised and illustrated&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#organised-and-illustrated&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-2-9f42ae2f41b319470f65ad9ec5928524.png&quot; alt=&quot;Legacy Codebase pointing into an Architecture Map of Module A, Module B, Module C, each with its own .md spec.&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The specification&amp;#39;s organisation should mirror the codebase&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;A single big text file describing a whole legacy system is only useful for very small projects. You want the spec broken
down by module — by bounded context — and the modules themselves arranged on a map of the architecture. Once each module
has its own spec associated to it, two things become possible: you can divide the work, and you can reason about
coverage.&lt;/p&gt;&lt;h3&gt;Job done! Or is it?&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#job-done-or-is-it&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;We now have and organised map of the system and a set of specifications. We have an overview, and the details. We could
now talk to the stakeholders, the development team, and set to work.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-3-f8cc173a463a67d9b7f04b22acfa704a.png&quot; alt=&quot;A graphic showing rectangles representing the software parts map to rectangles representing the specification&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The specification&amp;#39;s organisation should mirror the codebase&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;&lt;strong&gt;However, we have not yet verified our findings.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;What we are looking at is not yet a specification of the legacy system — it is a &lt;em&gt;speculation&lt;/em&gt; of the legacy system.
A document that &lt;strong&gt;claims&lt;/strong&gt; to describe what the code does, written by reading the code (or by asking an LLM to read the code).
Plausible, well-organised, reviewable. But unverified.&lt;/p&gt;&lt;h4&gt;1. Verifiability&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#1-verifiability&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;This is &lt;strong&gt;speculation as specification&lt;/strong&gt;, and it&amp;#39;s the trap most &amp;quot;let&amp;#39;s document the legacy system&amp;quot; projects fall into.
A Word document of &amp;quot;what the system does&amp;quot; can be worse than no document at all, because people start trusting it.
But even if the specification is done as a group effort by all stakeholders involved, and is thoroughly verified, it
still lacks one critical property:&lt;/p&gt;&lt;h4&gt;2. Reproducibility&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#2-reproducibility&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;What we actually want is a &lt;strong&gt;verifiable specification&lt;/strong&gt;: Not &amp;quot;verified once,&amp;quot; but verifiable on demand, any time someone
asks the question. You could verify a paper spec by hand, of course — sit down, read it, run the system, tick off the
scenarios — but manual verification is expensive enough that it gets done once at sign-off and never again.&lt;/p&gt;&lt;p&gt;A verification you don&amp;#39;t actually do is verification that doesn&amp;#39;t exist. And unless the legacy codebase is a museum
exhibit, it&amp;#39;s a moving target:&lt;/p&gt;&lt;p&gt;The team is still shipping fixes, the system is still drifting, and a paper spec written on Monday is no longer accurate
by Friday.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-6-ff8b98aeca5f9fdf5732fe355a44fbc0.png&quot; alt=&quot;Same picture but with a red horse galloping across the bottom and the words &amp;amp;quot;BUT SPECS GO STALE&amp;amp;quot;. The Legacy Codebase is now annotated as &amp;amp;quot;A moving target&amp;amp;quot;.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;h3&gt;Runnable specs&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#runnable-specs&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The fix is to make the specs &lt;strong&gt;executable&lt;/strong&gt; — to wire them into something that can run them against the actual legacy system, on demand, and tell you whether they still hold.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-7-444bd86f1323e81fa52511f1d57830f1.png&quot; alt=&quot;The earlier picture with a new &amp;amp;quot;SOME MAGIC&amp;amp;quot; box added below, with arrows looping the .assay specs back through the magic box and into the legacy codebase.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;That is the move from speculation to verifiable specification: not a one-shot audit, but a button you can press on every commit, every nightly, every time anyone asks &amp;quot;is this still true?&amp;quot;&lt;/p&gt;&lt;p&gt;A &lt;strong&gt;green&lt;/strong&gt; run proves the spec is still true. A &lt;strong&gt;red&lt;/strong&gt; run is a useful signal: either the legacy drifted, or your spec was wrong.
Either way, you find out before the rewrite, not during it.&lt;/p&gt;&lt;h3&gt;That magic is Assay&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#that-magic-is-assay&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-8-c06ddef9a1740cfb355acb10dace6184.png&quot; alt=&quot;The bottom box is now labelled &amp;amp;quot;Assay — A framework to automate assertions&amp;amp;quot; with a Parser that reads .assay files and a &amp;amp;quot;(The parts you fill in)&amp;amp;quot; placeholder beneath it.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Assay is the runner.
It parses the &lt;code&gt;.assay&lt;/code&gt; specs Surveyor produced, matches each step against bindings you write, and exercises the real legacy system through whatever interface it actually exposes — HTTP, CLI, message queue, file drop, whatever.
The framework is small.&lt;/p&gt;&lt;p&gt;The runtime contract is &amp;quot;your bindings + your assertions + a deterministic runner.&amp;quot;&lt;/p&gt;&lt;h3&gt;The systemic approach&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-systemic-approach&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/overview-110149d565e655cbe254faa9456037a1.png&quot; alt=&quot;The full picture: Surveyor on top with Architecture Map and .assay specs; Assay below with Parser, Pattern Recognition, Bindings, and Given/When/Then automations; arrows looping back to the Legacy Codebase.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Open up that &amp;quot;parts you fill in&amp;quot; box and you find the actual surface area of work: pattern recognition for step matching, a binding per target system, and Given/When/Then automations for setup, action, and verification.&lt;/p&gt;&lt;p&gt;The end state has a property that nothing in the earlier pictures had: the problem space has been &lt;strong&gt;dissected into manageable pieces&lt;/strong&gt;.
Architecture, behavior, target adapters, assertions — each is small, each can be reviewed independently, and each can be picked up by a human or an agent without having to hold the whole system in their head at once.&lt;/p&gt;&lt;p&gt;That&amp;#39;s the goal of the toolkit.
Everything below is the detail of how each piece is built.&lt;/p&gt;&lt;h2&gt;Surveyor — discovering the architecture you inherited&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#surveyor--discovering-the-architecture-you-inherited&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Surveyor is a Mix project that takes a path to a codebase and produces a &lt;a href=&quot;https://structurizr.com/&quot;&gt;Structurizr&lt;/a&gt; DSL workspace file.
It runs in three phases mapping to the C4 model: C1 (system context), C2 (containers), C3 (components per container).&lt;/p&gt;&lt;p&gt;Crucially, Surveyor does &lt;strong&gt;not&lt;/strong&gt; parse code.
There are no language-specific parsers, no AST walkers, no fragile grammar files for thirty different ecosystems.&lt;/p&gt;&lt;p&gt;It scans the filesystem to identify what&amp;#39;s there — languages, frameworks, project files, entry points, deployment manifests — and then feeds meaningful chunks to an LLM. That sounds trivial, but is the same mechanism that commercial coding agents use when
they analyse your code.&lt;/p&gt;&lt;p&gt;Surveyor&amp;#39;s intelligence is in the prompts and the chunking, not in a stack of half-broken language adapters.&lt;/p&gt;&lt;p&gt;The CLI is interactive at every phase.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;$ surveyor ./legacy-monolith --phase c1

Phase 1 — System Context
  Scanning codebase...
  Querying LLM...

  System: &amp;quot;Order Management System&amp;quot;
  Description: Manages the full order lifecycle

  Actors:
    ✓ Customer — places and tracks orders via Web UI
    ✓ Warehouse Staff — manages fulfillment via Back Office
    ? Admin — found references in auth config (confidence: low)

  External Systems:
    ✓ Stripe — payment processing (REST API)
    ? SendGrid — found API key in config (confidence: low)

[a]ccept  [e]dit  [r]etry with more context  [q]uit
&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The LLM is asked to flag uncertainty.
Anything tagged &lt;code&gt;confidence: low&lt;/code&gt; shows up with a &lt;code&gt;?&lt;/code&gt; and a short reasoning string for human review.
That&amp;#39;s not just nice ergonomics — it&amp;#39;s the bit that makes the tool trustworthy.
An LLM that confidently invents a &lt;code&gt;PaymentReconciliation&lt;/code&gt; container that doesn&amp;#39;t exist is worse than no model at all.
An LLM that says &amp;quot;I see a &lt;code&gt;SENDGRID_API_KEY&lt;/code&gt; in &lt;code&gt;.env.example&lt;/code&gt; but no Sendgrid client in the source, please confirm&amp;quot; is doing the right kind of work.&lt;/p&gt;&lt;p&gt;Each phase is resumable.
Results are saved as JSON in &lt;code&gt;./surveyor/&lt;/code&gt;, so a thirty-container system doesn&amp;#39;t have to finish in one sitting.
You can hop in, accept C1, take a break, come back tomorrow, and resume at C2.&lt;/p&gt;&lt;p&gt;The end product is a &lt;code&gt;workspace.dsl&lt;/code&gt; you can render in Structurizr.
But more importantly, it&amp;#39;s a &lt;code&gt;workspace.dsl&lt;/code&gt; whose components are decorated with &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties, pointing at the behavioral specs and the domain schemas for each bounded context.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;orderLifecycle = component &amp;quot;Order Lifecycle&amp;quot; &amp;quot;Orders&amp;quot; &amp;quot;Bounded Context&amp;quot; {
    properties {
        &amp;quot;assay.specs&amp;quot; &amp;quot;specs/order-lifecycle&amp;quot;
        &amp;quot;assay.schema&amp;quot; &amp;quot;schemas/order_lifecycle.ex&amp;quot;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;DDD pattern annotations from the LLM (&lt;code&gt;customer-supplier&lt;/code&gt;, &lt;code&gt;anticorruption-layer&lt;/code&gt;, …) ride along as properties on the relationship.
Every component gets &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties — the contract the next tool reads from.&lt;/p&gt;&lt;h3&gt;Examples&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#examples&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;h4&gt;The System Context of a Medical Application&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-system-context-of-a-medical-application&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Surveyor identified the actors and the external systems of the application. Without knowing the code, we would already
know which actor types there are&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/MeditechsystemContext-b7cb1953223f982d281b4b7110677683.png&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The System Context of a medical application&lt;/figcaption&gt;&lt;/figure&gt;&lt;h4&gt;An example of an assay spec created in the second phase:&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#an-example-of-an-assay-spec-created-in-the-second-phase&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/MeditechAssay-c4-46cf27120239ca00a3e2a7202cf32936.png&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The Account Creation Specs of a medical application&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2&gt;Assay — proving the rewrite behaves the same&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#assay--proving-the-rewrite-behaves-the-same&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Assay is a minimal behavioral spec runner.
Specs are plain text and look like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;Component: [orderLifecycle] Order Lifecycle
Context: Order Placement

Definitions:
  - &amp;quot;a valid customer&amp;quot; means:
      a Customer with status Active, verified email,
      and a credit limit greater than zero

Invariants:
  - total must not exceed customer credit limit
  - at least one line item required

Rule: Customers can place orders for in-stock items

  @critical @phase-1
  Scenario: [OL-001] Place a simple order
    Given a valid customer &amp;quot;Alice&amp;quot; with credit limit €10,000
    And product &amp;quot;Widget&amp;quot; is in stock with 50 units available
    When Alice places an order for 3 units of &amp;quot;Widget&amp;quot;
    Then the order status becomes &amp;quot;Placed&amp;quot;
    And stock for &amp;quot;Widget&amp;quot; is reduced to 47 units&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The bracketed &lt;code&gt;[orderLifecycle]&lt;/code&gt; is a workspace.dsl identifier — the same identifier Surveyor wrote into the architecture model.
The bracketed &lt;code&gt;[OL-001]&lt;/code&gt; on the scenario is an optional free-form id that survives across rewrites of the spec text and lets you cross-reference from tickets or audit trails.&lt;/p&gt;&lt;p&gt;If that looks like Cucumber or Gherkin, it does — with one large difference.
There is no glue layer, no abstraction over what kind of system you are testing, no framework opinions about HTTP, databases, or browsers.
The bindings are just Elixir.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleTargets.Legacy.OrderLifecycledo
useAssay.Binding,component:&amp;quot;orderLifecycle&amp;quot;

@baseSystem.get_env(&amp;quot;LEGACY_API_URL&amp;quot;)

  step :given,~r/a valid customer &amp;quot;(?P&amp;lt;name&amp;gt;.+)&amp;quot; with credit limit €(?P&amp;lt;limit&amp;gt;[\d,.]+)/do
{:ok, resp}=Req.post(&amp;quot;#{@base}/test/customers&amp;quot;,json:%{
name:params().name,
credit_limit:parse_money(params().limit),
status:&amp;quot;Active&amp;quot;
})
assign(customer_id: resp.body[&amp;quot;id&amp;quot;])
end

  step :action,~r/.+ places an order for (?P&amp;lt;qty&amp;gt;\d+) units? of &amp;quot;(?P&amp;lt;product&amp;gt;.+)&amp;quot;/do
{:ok, resp}=Req.post(&amp;quot;#{@base}/orders&amp;quot;,json:%{
customer_id:var(:customer_id),
lines:[%{product_id:var(:product_id),quantity:to_int(params().qty)}]
})
assign(order_id: resp.body[&amp;quot;id&amp;quot;],http_status: resp.status)
end

  step :expect,~r/the order status becomes &amp;quot;(?P&amp;lt;status&amp;gt;.+)&amp;quot;/do
{:ok, resp}=Req.get(&amp;quot;#{@base}/orders/#{var(:order_id)}&amp;quot;)
    assert resp.body[&amp;quot;status&amp;quot;]==params().status
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A binding is just a module that brings whatever it needs — &lt;code&gt;Req&lt;/code&gt; for an HTTP API, &lt;code&gt;AMQP&lt;/code&gt; for a message broker, &lt;code&gt;System.cmd&lt;/code&gt; for a batch processor, &lt;code&gt;File&lt;/code&gt; for a directory-watching pipeline.
Assay itself does not ship an HTTP client or a database adapter.&lt;/p&gt;&lt;p&gt;The framework provides parsing, step matching, scenario lifecycle, and assertions; the rest is your code.
That&amp;#39;s what makes the same spec runnable against an HTTP API, a CLI tool, a batch job, or a message queue, depending on what the legacy system happens to be.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Would you like to run this on your legacy codebase? Sign up for early access! &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The crucial property is target swappability:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;assay run specs/order-lifecycle/ --target legacy
assay run specs/order-lifecycle/ --target new&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Same specs. Different bindings. Different systems.
The legacy binding hits the old SOAP API; the new binding hits the new Phoenix endpoint.
If both go green, the rewrite is behavior-equivalent for the things the spec covers — which, after a few months of writing specs, is most of the things that matter.&lt;/p&gt;&lt;p&gt;There is no separate &lt;code&gt;.exs&lt;/code&gt; file generated from the specs.
The runner parses &lt;code&gt;.assay&lt;/code&gt; files, matches each step against a regex on a binding function, executes them, and prints pass/fail.
That&amp;#39;s it.&lt;/p&gt;&lt;h3&gt;How Assay actually works&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-assay-actually-works&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The runtime is around six hundred lines of Elixir.
Five design choices do most of the work.&lt;/p&gt;&lt;h4&gt;A two-pass parser&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#a-two-pass-parser&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;The first pass lifts triple-quoted doc strings out of the file (so the line tokenizer doesn&amp;#39;t have to reason about them).
The second pass dispatches on the first keyword on each line.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defparse_string(content)do
{filtered, doc_strings}=
    content
|&amp;gt;String.split(&amp;quot;\n&amp;quot;)
|&amp;gt;Enum.with_index(1)
|&amp;gt;extract_doc_strings()

Process.put(:assay_doc_strings, doc_strings)

  filtered
|&amp;gt;Enum.reject(fn{line, _}-&amp;gt;
    trimmed =String.trim(line)
    trimmed ==&amp;quot;&amp;quot;orString.starts_with?(trimmed,&amp;quot;#&amp;quot;)
end)
|&amp;gt;parse_lines(%Spec{})
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Output is a plain &lt;code&gt;%Spec{}&lt;/code&gt; struct. No AST nodes, no string interpolation.&lt;/p&gt;&lt;h4&gt;The step macro generates real module functions&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-step-macro-generates-real-module-functions&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Each &lt;code&gt;step :given, ~r/.../ do ... end&lt;/code&gt; call expands into a uniquely-named function plus a &lt;code&gt;%StepBinding{}&lt;/code&gt; record on an accumulating attribute.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmacrostep(type, regex,do: block)
when type in[:given,:action,:expect]do
  fn_name = :&amp;quot;__step_#{:erlang.unique_integer([:positive])}__&amp;quot;

quotedo
@assay_steps{unquote(type),unquote(regex),&amp;amp;__MODULE__.unquote(fn_name)/0}

defunquote(fn_name)()do
unquote(block)
end
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;At compile time, &lt;code&gt;__before_compile__&lt;/code&gt; exposes &lt;code&gt;__steps__/0&lt;/code&gt; and &lt;code&gt;__component__/0&lt;/code&gt; for the runner to read.
There is no string codegen, no eval — just normal Elixir function definitions.&lt;/p&gt;&lt;h4&gt;Component-scoped step matching&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#component-scoped-step-matching&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Anyone who has used Cucumber has hit the global step-definition problem: the same step text means different things in different contexts, but Gherkin treats step definitions as one shared namespace.
Assay scopes bindings by component:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defprun_spec(spec, bindings, target, tags, exclude_tags)do
  component_bindings =
Enum.filter(bindings,fn b -&amp;gt; b.component == spec.component end)

# ... run each scenario against component_bindings only
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is the direct reason Surveyor and Assay use the same identifier convention.
The architecture model and the spec runner share a vocabulary, and step text never collides across bounded contexts.&lt;/p&gt;&lt;h4&gt;Pre-check, then execute&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#pre-check-then-execute&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Before running any step, the runner walks the whole scenario and finds a binding for every step.
If anything is unbound, the scenario fails before any side effects.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defrun_scenario(scenario, bindings)do
  matched_steps =
Enum.map(scenario.steps,fn step -&amp;gt;
casefind_binding(step, bindings)do
nil-&amp;gt;{:unbound, step}
        binding -&amp;gt;{:bound, step, binding}
end
end)

ifEnum.any?(matched_steps,&amp;amp;match?({:unbound, _},&amp;amp;1))do
%ScenarioResult{status::error,error:&amp;quot;Unbound steps found&amp;quot;}
else
execute_matched_steps(scenario, matched_steps, bindings)
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is what stops a scenario from running half its Givens, hitting an unbound When, and leaving a dangling test customer in the database.&lt;/p&gt;&lt;h4&gt;Per-scenario state in the process dictionary&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#per-scenario-state-in-the-process-dictionary&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Each scenario gets a fresh context — a tiny module that stores variables, params, doc strings, and data tables in the process dictionary.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;def init do
Process.put(@vars_key,%{})
Process.put(@params_key,%{})
end

defget_var(name)do
Process.get(@vars_key,%{})|&amp;gt;Map.get(name)
end

defset_vars(keyword)do
  vars =Process.get(@vars_key,%{})
Process.put(@vars_key,Enum.into(keyword, vars))
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The runner can &lt;code&gt;save/0&lt;/code&gt; and &lt;code&gt;restore/1&lt;/code&gt; the snapshot, which is how Assay tests itself by running its own &lt;code&gt;.assay&lt;/code&gt; specs through itself in the same process.&lt;/p&gt;&lt;p&gt;There is no plugin system, no dependency injection, no scenario hooks beyond &lt;code&gt;cleanup&lt;/code&gt;.
Adding any of those would push complexity into the framework and out of the binding, which is the wrong direction.
The framework exists to be small enough that you can read it on a Friday afternoon and trust it on Monday.&lt;/p&gt;&lt;h2&gt;How they fit together&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-they-fit-together&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The flow is straightforward and it maps onto the five phases laid out in the Assay handbook.&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;strong&gt;Discovery (Surveyor).&lt;/strong&gt; Run Surveyor against the legacy codebase. Walk through the C1/C2/C3 output interactively. Edit, retry, accept. Produces &lt;code&gt;workspace.dsl&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Behavioral extraction.&lt;/strong&gt; For each component in the model, write &lt;code&gt;.assay&lt;/code&gt; specs and an Elixir schema. The &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties on every Structurizr component tell you exactly where they go: &lt;code&gt;specs/&amp;lt;context&amp;gt;/&lt;/code&gt; and &lt;code&gt;schemas/&amp;lt;context&amp;gt;.ex&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Validation.&lt;/strong&gt; Write bindings against the legacy system in &lt;code&gt;targets/legacy/&lt;/code&gt;. Run &lt;code&gt;assay run specs/ --target legacy&lt;/code&gt;. Iterate until green. You now have a verified, executable specification of the legacy system&amp;#39;s behavior.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Stabilization.&lt;/strong&gt; Review what&amp;#39;s covered, what&amp;#39;s missing, what&amp;#39;s flagged as ambiguous. Get sign-off on scope.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Rewrite.&lt;/strong&gt; Build the new system. Write bindings against it in &lt;code&gt;targets/new/&lt;/code&gt;. Run &lt;code&gt;assay run specs/ --target new&lt;/code&gt;. When that&amp;#39;s green, the rewrite is done — at least for the surface area the specs cover.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The reason the two tools sit next to each other is that the architecture model is what gives the spec work structure.
Without a model, &amp;quot;write specs for the legacy system&amp;quot; is an open-ended task with no obvious stopping point.
With the model, every component has a &lt;code&gt;specs/&lt;/code&gt; directory and the question becomes &amp;quot;is each context&amp;#39;s behavior covered?&amp;quot;
That&amp;#39;s tractable.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Would you like to run this on your legacy codebase? Sign up for early access! &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The cross-target story is what makes the rewrite verifiable.
The same parsed &lt;code&gt;.assay&lt;/code&gt; scenarios are dispatched, step by step, through two different binding modules at two different points in the project&amp;#39;s life:&lt;/p&gt;&lt;p&gt;Same spec, two bindings, two systems.
The runner doesn&amp;#39;t know or care which target it&amp;#39;s dispatching to — that&amp;#39;s the property that makes the rewrite a measurable thing rather than a leap of faith.&lt;/p&gt;&lt;h2&gt;Four audiences, one artifact&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#four-audiences-one-artifact&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Four very different parties end up reading — or executing — the same &lt;code&gt;.assay&lt;/code&gt; file: the &lt;strong&gt;product owner&lt;/strong&gt;, the &lt;strong&gt;developer&lt;/strong&gt;, the &lt;strong&gt;coding agent&lt;/strong&gt;, and the &lt;strong&gt;test runner&lt;/strong&gt;.
The interesting observation is that they don&amp;#39;t conflict on &lt;strong&gt;content&lt;/strong&gt; — they conflict on &lt;strong&gt;form&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Product owners&lt;/strong&gt; want to know what the system does in business terms, what would break if you changed X, and where the risk lives.
They don&amp;#39;t want UML.
They want to read scenarios in their domain vocabulary, in plain language, and trust them.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Developers&lt;/strong&gt; want to know how the system is structured, where the seams are, and what invariants they mustn&amp;#39;t break.
And — crucially — they want the documentation to be wrong less often than the code is.
The moment docs lie, developers stop reading them.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Coding agents&lt;/strong&gt; want machine-parseable, unambiguous, addressable artifacts with stable identifiers.
They want to be able to say &amp;quot;the assertion at &lt;code&gt;assay://orderLifecycle/order-placement/OL-001/step-3&lt;/code&gt; failed&amp;quot; and have that mean something durable.
They want types, schemas, and graphs they can traverse.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Automated tests&lt;/strong&gt; want executable bindings — the layer Assay provides — and they want failures that point back to the spec, not just to a line of code.&lt;/p&gt;&lt;p&gt;Same facts, four presentations.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;.assay&lt;/code&gt; file gives the product owner readable scenarios in domain language.
The &lt;code&gt;workspace.dsl&lt;/code&gt; plus the schema modules give the developer the structural truth.
The bracketed identifiers (&lt;code&gt;Component: [orderLifecycle]&lt;/code&gt;, &lt;code&gt;Scenario: [OL-001]&lt;/code&gt;) give the agent its stable addresses.
The runner turns the whole thing into a regression suite.
None of these audiences is asked to read the others&amp;#39; representation; they all read the same spec, surfaced at the level of detail they need.&lt;/p&gt;&lt;h2&gt;Where the LLM lives, and where it doesn&amp;#39;t&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#where-the-llm-lives-and-where-it-doesnt&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;A reasonable question at this point: with all the recent enthusiasm for LLMs, why isn&amp;#39;t more of this LLM-driven?&lt;/p&gt;&lt;p&gt;The split is deliberate.&lt;/p&gt;&lt;p&gt;Surveyor uses an LLM because architectural discovery is genuinely a language task — you&amp;#39;re reading config files, route definitions, deployment manifests, dependency lock files, and inferring &amp;quot;this is the API, that&amp;#39;s the worker, that&amp;#39;s the read model.&amp;quot;
That&amp;#39;s exactly the kind of fuzzy, evidence-weighing job an LLM does well, especially when you tell it to flag uncertainty rather than guess.&lt;/p&gt;&lt;p&gt;Assay does &lt;strong&gt;not&lt;/strong&gt; use an LLM at runtime.
The runner is deterministic: parse the spec, match the regex, execute the binding, assert.
A behavioral spec that sometimes passes and sometimes does not, depending on which model variant happened to answer, is not a spec.&lt;/p&gt;&lt;p&gt;LLMs are very welcome on the authoring side — drafting &lt;code&gt;.assay&lt;/code&gt; files from legacy source is a great agent task, and the handbook has explicit guidance for coding agents about how to do that without inventing behavior.
But the green/red signal at the end has to come from a deterministic runner, or it is not really a signal.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Sign up for early access:&lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;How you use them on a project&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-you-use-them-on-a-project&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In practice, the first day of a legacy rewrite looks something like this:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Clone the repo. Run Surveyor with &lt;code&gt;--phase c1&lt;/code&gt; to get the system in context. Discuss the actors and external systems with the client — this alone surfaces &amp;quot;wait, who is the Admin role?&amp;quot; conversations that would otherwise happen three months in.&lt;/li&gt;&lt;li&gt;Run &lt;code&gt;--phase c2&lt;/code&gt; to map containers. This is where you discover the data plane that nobody documented, the cron job running on a forgotten server, the queue that two services share. Surveyor flags &lt;code&gt;confidence: low&lt;/code&gt; items; the human resolves them.&lt;/li&gt;&lt;li&gt;Run &lt;code&gt;--phase c3&lt;/code&gt; per container. By the end you have a &lt;code&gt;workspace.dsl&lt;/code&gt; that is, for the first time in the project&amp;#39;s history, an accurate picture of what is deployed.&lt;/li&gt;&lt;li&gt;Pick the highest-risk bounded context. Read the legacy source. Draft &lt;code&gt;.assay&lt;/code&gt; specs for its behavior. Write a legacy binding. Run it. Iterate until green.&lt;/li&gt;&lt;li&gt;Repeat per context, in priority order, until coverage is good enough to start the rewrite.&lt;/li&gt;&lt;li&gt;Build the new system, one bounded context at a time, with &lt;code&gt;targets/new/&lt;/code&gt; bindings going green as each context comes online.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The thing both tools are optimized for is the same: making the work legible.
A legacy rewrite is a long project with shifting personnel and a nervous client.
Both Surveyor and Assay produce artifacts — a &lt;code&gt;workspace.dsl&lt;/code&gt; and a directory of &lt;code&gt;.assay&lt;/code&gt; files — that survive turnover, show progress, and that the client can actually read.&lt;/p&gt;&lt;h2&gt;Status and next steps&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#status-and-next-steps&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Both tools are ready to be tested in real world scenarios. &lt;strong&gt;This is where we need your help&lt;/strong&gt;:&lt;/p&gt;&lt;p&gt;If you have a legacy system staring you down and a rewrite on the roadmap, &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;get in touch&lt;/a&gt;. We would love to hear from you!&lt;/p&gt;</content:encoded>
</item>
</channel>
</rss>
