<?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>Objects, States, and Stewards: Introducing Generic Workflows to an Existing Project · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/objects-states-and-stewards</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-objects-states-and-stewards.png"></enclosure>
<guid isPermaLink="false">_nrR9K9hmtH_S9qcmGSLSyIr5Be0RWgXcyINTw==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>The urge to automate business processes rarely strikes on day one of a greenfield project. On day one, you have two database tables, a signup form, and the blissful delusion that business logic will always fit inside three clean functions.</description>
<content:encoded>&lt;p&gt;
  The urge to automate business processes rarely strikes on day one of a greenfield project. On day one, you have two database tables, a signup form, and the blissful delusion that business logic will always fit inside three clean functions.
&lt;/p&gt;&lt;p&gt;
  The urge arrives two years later. 
&lt;/p&gt;&lt;p&gt;
  By then, you have customer accounts, billing runs, support tickets, inventory shipments, and legal agreements. You also have half a dozen ad-hoc “processes” living as comments inside controller actions, stray background workers checking for boolean flags in cron loops, and angry messages in Slack asking why an invoice was refunded before anyone signed off on the return.
&lt;/p&gt;&lt;p&gt;
  Management has a simple request: 
  &lt;i&gt;
    “We need workflows.”
  &lt;/i&gt;
&lt;/p&gt;&lt;p&gt;
  At this junction, engineering teams usually make one of two catastrophic mistakes:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Ad-Hoc Infection:
    &lt;/strong&gt;
     They bolt a bespoke 
&lt;code class=&quot;code-inline&quot;&gt;workflow_status&lt;/code&gt;
     column and a handful of conditional 
&lt;code class=&quot;code-inline&quot;&gt;case&lt;/code&gt;
     statements onto every existing model. Within three sprints, five separate teams have implemented five incompatible ways to move records around, send emails, and check permissions.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Heavyweight Invader:
    &lt;/strong&gt;
     They purchase or deploy an enterprise BPMN orchestrator or microservice engine that demands every domain object be serialized into generic JSON payloads and piped through a foreign external runtime.
  &lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;
  There is a third way: introducing a 
  &lt;strong&gt;
    lean, generic, and agnostic workflow machinery
  &lt;/strong&gt;
   directly into your existing project. It respects your existing domain models, enforces strict state transitions, classifies access through stewardship, notifies the right people without hardcoding recipient lists, and uses templates so that adding your twentieth workflow is as pleasant and predictable as adding your first.
&lt;/p&gt;&lt;p&gt;
  Here is the blueprint.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Four Pillars of Agnostic Machinery
&lt;/h2&gt;&lt;p&gt;
  A generic workflow engine does not need to know what an invoice, a pull request, or a customer refund actually is. It only needs to understand four orthogonal concepts:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;mermaid lang-mermaid&quot;&gt;flowchart LR
    Subject[&amp;quot;Attached Object(s)\n(Domain Reality)&amp;quot;] --- Engine[&amp;quot;Workflow Machinery\n(Agnostic Core)&amp;quot;]
    Topology[&amp;quot;States &amp;amp; Transitions\n(Discrete Topology)&amp;quot;] --- Engine
    Stewardship[&amp;quot;Stewardship\n(Classified Access)&amp;quot;] --- Engine
    TWIMC[&amp;quot;TWIMC Notifications\n(Decoupled Broadcast)&amp;quot;] --- Engine&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;
  ①  Attached Objects: Holding the Leash, Not the Data
&lt;/h3&gt;&lt;p&gt;
  The biggest architectural blunder is letting the workflow engine consume or duplicate the domain model. An 
&lt;code class=&quot;code-inline&quot;&gt;Invoice&lt;/code&gt;
   belongs in your billing context. A 
&lt;code class=&quot;code-inline&quot;&gt;Contract&lt;/code&gt;
   belongs in your legal context. 
&lt;/p&gt;&lt;p&gt;
  The workflow instance merely 
  &lt;strong&gt;
    attaches
  &lt;/strong&gt;
   to the domain object via a polymorphic handle:
&lt;/p&gt;&lt;p&gt;
  The domain object is the 
  &lt;strong&gt;
    subject
  &lt;/strong&gt;
  . The workflow instance is the 
  &lt;strong&gt;
    governor
  &lt;/strong&gt;
  . 
&lt;/p&gt;&lt;p&gt;
  The workflow holds the leash: it knows 
  &lt;i&gt;
    where
  &lt;/i&gt;
   the subject is in its lifecycle, but it does not dictate its database schema. If a process coordinates multiple objects—say, an 
&lt;code class=&quot;code-inline&quot;&gt;Order&lt;/code&gt;
   and three 
&lt;code class=&quot;code-inline&quot;&gt;Shipment&lt;/code&gt;
   records—the primary subject remains the anchor, while related entity IDs travel inside the workflow’s transient 
&lt;code class=&quot;code-inline&quot;&gt;context&lt;/code&gt;
   payload.
&lt;/p&gt;&lt;h3&gt;
  ②  States in Between: Discrete Coordinates
&lt;/h3&gt;&lt;p&gt;
  A workflow is not a nebulous continuum; it is a finite graph. Between the moment a request is born and the moment it terminates, it occupies exactly one discrete state at any given second.
&lt;/p&gt;&lt;p&gt;
  Transitions between states must be:
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Explicit:
    &lt;/strong&gt;
     Only declared edges in the graph can be traversed.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Atomic:
    &lt;/strong&gt;
     The state change and any accompanying subject mutations succeed or fail together inside a single database transaction.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Validated:
    &lt;/strong&gt;
     The engine rejects any event that does not match the current coordinate.
  &lt;/li&gt;
&lt;/ul&gt;&lt;h3&gt;
  ③  Stewardship: Contextual Authorization
&lt;/h3&gt;&lt;p&gt;
  Coarse-grained permissions (such as 
&lt;code class=&quot;code-inline&quot;&gt;user.role == :admin&lt;/code&gt;
  ) collapse the moment real human workflows enter the picture. Who is allowed to approve an expense report? The author cannot approve their own report. The department head can approve up to $5,000. Legal must sign off if it touches international compliance.
&lt;/p&gt;&lt;p&gt;
  We call this 
  &lt;strong&gt;
    Stewardship
  &lt;/strong&gt;
  : access classified and regulated by the actor’s relationship to the subject and the specific transition being attempted.
&lt;/p&gt;&lt;p&gt;
  Under stewardship, an action is not authorized by a static global role, but by evaluating three variables:
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Actor:
    &lt;/strong&gt;
     Who is knocking at the door?
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Transition:
    &lt;/strong&gt;
     Which edge are they trying to cross?
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Subject Context:
    &lt;/strong&gt;
     What is the actor’s mandate relative to this specific object in this specific state?
  &lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;
  A transition declares its required stewardship policy: 
&lt;code class=&quot;code-inline&quot;&gt;:author&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:designated_reviewer&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:finance_steward&lt;/code&gt;
  , or 
&lt;code class=&quot;code-inline&quot;&gt;:system_automation&lt;/code&gt;
  . If the actor fails the stewardship check, the transition is rejected at the gate before any business code runs.
&lt;/p&gt;&lt;h3&gt;
  ④  Notifications TWIMC: To Whom It May Concern
&lt;/h3&gt;&lt;p&gt;
  In dirty codebases, notification logic is smeared across the application layer:
&lt;/p&gt;&lt;p&gt;
  Every new requirement adds another line of side-effect spaghetti. If one notification fails, does the transaction roll back? Who knows.
&lt;/p&gt;&lt;p&gt;
  In generic workflow machinery, transitions emit a standardized event:
&lt;/p&gt;&lt;p&gt;
  A dedicated 
  &lt;strong&gt;
    TWIMC Dispatcher
  &lt;/strong&gt;
   catches this event asynchronously. It looks up the notification rules declared for that transition, resolves the recipient list dynamically (e.g., the subject’s author, the current assignees, or an external webhook), and dispatches the alerts. 
&lt;/p&gt;&lt;p&gt;
  The transition logic remains pristine and focused entirely on state validation.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  Workflow Templates: Guarding Against Chaos
&lt;/h2&gt;&lt;p&gt;
  If every developer is permitted to write ad-hoc state-machine plumbing, your system quickly degrades into an unmaintainable thicket of conflicting patterns. 
&lt;/p&gt;&lt;p&gt;
  The antidote is 
  &lt;strong&gt;
    Workflow Templates
  &lt;/strong&gt;
  : declarative specifications that define the entire contract of a process in one place.
&lt;/p&gt;&lt;p&gt;
  A template specifies:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Target Subject:
    &lt;/strong&gt;
     What type of domain object this workflow attaches to.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      States &amp;amp; Transitions:
    &lt;/strong&gt;
     The allowed lifecycle topology.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Stewardship Rules:
    &lt;/strong&gt;
     Which actors can trigger each transition.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      TWIMC Hooks:
    &lt;/strong&gt;
     Who gets notified when a transition occurs.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Side-Effect Actions:
    &lt;/strong&gt;
     Automated callbacks executed on successful transitions.
  &lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;
  Here is what a declarative template looks like in practice:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup elixir&quot;&gt;defmodule MyApp.Workflows.Templates.ExpenseReport do
  use MyApp.Workflow.Template,
    subject: MyApp.Expenses.Report,
    initial: :draft

  # State topology
  transition :submit, from: :draft, to: :pending_review do
    stewardship :author_only
    notify :stewards, role: :department_manager
  end

  transition :approve, from: :pending_review, to: :approved do
    stewardship MyApp.Expenses.Stewardship.ReviewerPolicy
    action MyApp.Expenses.Actions.SchedulePayment
    notify :author
    notify :channel, target: &amp;quot;#finance-approvals&amp;quot;
  end

  transition :reject, from: :pending_review, to: :rejected do
    stewardship MyApp.Expenses.Stewardship.ReviewerPolicy
    notify :author, template: &amp;quot;expense_rejected&amp;quot;
  end

  transition :payout, from: :approved, to: :settled do
    stewardship :system_automation
    notify :author
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Notice what just happened:
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    No database migrations were needed to invent a new process.
  &lt;/li&gt;
  &lt;li&gt;
    The states, transitions, security rules, and communication policies are visible at a single glance.
  &lt;/li&gt;
  &lt;li&gt;
    The business logic for paying out or reviewing is cleanly isolated in modular action and policy modules.
  &lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;h2&gt;
  The Execution Cycle
&lt;/h2&gt;&lt;p&gt;
  When an actor attempts to move a process forward, the generic engine executes a disciplined sequence:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;mermaid lang-mermaid&quot;&gt;sequenceDiagram
    autonumber
    actor User as Actor
    participant Engine as Workflow Engine
    participant Policy as Stewardship Policy
    participant Subject as Attached Object (DB)
    participant Bus as TWIMC Bus

    User-&amp;gt;&amp;gt;Engine: advance(instance_id, :approve, actor, params)
    Engine-&amp;gt;&amp;gt;Engine: Verify state allows event (:pending_review -&amp;gt; :approved)
    Engine-&amp;gt;&amp;gt;Policy: check_stewardship(actor, transition, subject)
    alt Unauthorized
        Policy--&amp;gt;&amp;gt;Engine: {:error, :unauthorized}
        Engine--&amp;gt;&amp;gt;User: {:error, :forbidden}
    else Authorized
        Policy--&amp;gt;&amp;gt;Engine: :ok
        rect rgb(240, 245, 255)
            Note over Engine,Subject: Atomic Transaction
            Engine-&amp;gt;&amp;gt;Subject: Apply transition side-effects / validations
            Engine-&amp;gt;&amp;gt;Engine: Update current_state = :approved
        end
        Engine-&amp;gt;&amp;gt;Bus: publish_transition(transition_payload)
        Bus--&amp;gt;&amp;gt;User: (Async) Notify Author, Stewards, Slack
        Engine--&amp;gt;&amp;gt;User: {:ok, updated_instance}
    end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Every transition follows the exact same pipeline:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Topology Check:
    &lt;/strong&gt;
     Is the requested event permitted from the current state?
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Stewardship Gate:
    &lt;/strong&gt;
     Does the actor have the legitimate mandate to trigger this transition for this specific subject?
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Atomic Execution:
    &lt;/strong&gt;
     In a single transaction, execute the subject hook and update the workflow instance record (with optimistic locking to prevent race conditions).
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      TWIMC Broadcast:
    &lt;/strong&gt;
     Asynchronously publish the transition event to notify anyone who needs to know.
  &lt;/li&gt;
&lt;/ol&gt;&lt;hr/&gt;&lt;h2&gt;
  Bringing It into an Existing Codebase
&lt;/h2&gt;&lt;p&gt;
  You don’t need to rebuild your application to introduce this machinery. The migration path is non-invasive:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Add Two Tables:
    &lt;/strong&gt;
     You need 
&lt;code class=&quot;code-inline&quot;&gt;workflow_instances&lt;/code&gt;
     (to track state, template name, and polymorphic subject IDs) and an optional 
&lt;code class=&quot;code-inline&quot;&gt;workflow_logs&lt;/code&gt;
     table for audit trails. Your existing business tables don’t need to be redesigned.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Build the Registry:
    &lt;/strong&gt;
     Create a registry that maps template identifiers (e.g. 
&lt;code class=&quot;code-inline&quot;&gt;&amp;quot;expense_report&amp;quot;&lt;/code&gt;
    ) to their compiled template modules.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Wrap, Don’t Rewrite:
    &lt;/strong&gt;
     Pick one painful existing process—such as order returns or invoice approvals. Write a template for it. When an invoice is created, attach a new workflow instance to it.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Rely on the Harness:
    &lt;/strong&gt;
     Delegate all state progression, permission verification, and notifications to 
&lt;code class=&quot;code-inline&quot;&gt;WorkflowEngine.advance/4&lt;/code&gt;
    .
  &lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;
  Once the machinery is in place, creating a new workflow ceases to be a week-long ordeal of bespoke schema migrations and spaghetti event handlers. You define a template, attach your object, and let the machinery do the heavy lifting.
&lt;/p&gt;&lt;p&gt;
  Clear borders, classified stewardship, and automated communication. That is how you turn chaotic business requirements into predictable, pleasant engineering.
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Trouble Is My Workflow · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/trouble-is-my-workflow</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-trouble-is-my-workflow.png"></enclosure>
<guid isPermaLink="false">cG0IxdsSxMpOykSTXr0fUrdupxYiSbZ7nrCUzQ==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>The rain was hitting the asphalt outside with the dull, persistent rhythm of an unhandled exception in an infinite loop. It was past eight, the coffee in my mug was cold enough to preserve fish, and the only light in the office came from three flickering monitors and an incandescent exit sign that buzzed like an angry hornet in a tin can. It was 1998.</description>
<content:encoded>&lt;p&gt;
  The rain was hitting the asphalt outside with the dull, persistent rhythm of an unhandled exception in an infinite loop. It was past eight, the coffee in my mug was cold enough to preserve fish, and the only light in the office came from three flickering monitors and an incandescent exit sign that buzzed like an angry hornet in a tin can. It was 1998.
&lt;/p&gt;&lt;p&gt;
  That was when the architect walked in.
&lt;/p&gt;&lt;p&gt;
  You know the type. Rimless glasses, an ironed flannel shirt that had never seen manual labour, and the kind of earnest, predatory smile usually reserved for people selling whole-life insurance or enterprise service buses. He dropped a four-inch design document onto my desk. It landed with the sullen thud of a wet phonebook.
&lt;/p&gt;&lt;p&gt;
  “We need a generic workflow engine,” he announced, resting two manicured hands on the edge of my desk. “Something dynamic. Decoupled. Highly extensible. A unified platform to orchestrate our business processes across distributed microservices.”
&lt;/p&gt;&lt;p&gt;
  I looked at him through the steam of the fresh cup he hadn&amp;#39;t offered to buy me. 
&lt;/p&gt;&lt;p&gt;
  “You don’t need an engine,” I said. “You need an honest finite-state machine, and you’re too fancy-pants to admit it.”
&lt;/p&gt;&lt;p&gt;
  He flinched as if I’d insulted his mother, adjusted his glasses, and began to mumble about directed acyclic graphs. I lit a cigarette (these blessed years when you could smoke in the office,) pointed at the door, and waited for him to leave before I opened the file.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Autopsy of a Buzzword
&lt;/h2&gt;&lt;p&gt;
  In our trade, “workflow” is the word people use when they want to charge six figures for a product or excuse three quarters of architectural delinquency. It sounds respectable. It sounds like something an executive can nod at during a quarterly business review without having to know what a pointer does.
&lt;/p&gt;&lt;p&gt;
  Strip off the sales brochure, wipe the mud off the radiator grille, and look at the chassis underneath. What is a generic workflow, really, when nobody is trying to sell you a cloud subscription?
&lt;/p&gt;&lt;p&gt;
  At its bare brass tacks, a workflow is nothing more than five distinct elements cohabiting in a trench coat:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      A Finite Set of States.
    &lt;/strong&gt;
     Your process is always 
    &lt;b&gt;
      somewhere
    &lt;/b&gt;
    . Not everywhere at once, not halfway between two ideas like an indecisive juror, but occupying exactly one discrete coordinate in its lifecycle.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      A Discrete Set of Events.
    &lt;/strong&gt;
     Things happen. An alarm goes off, an HTTP webhook knocks on the door at two in the morning, a human reviewer clicks a green button, or a payment processor shrugs and gives up.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      A Transition Topology.
    &lt;/strong&gt;
     The rules of the alley. If you are standing in State A, there are only specific doors unlocked. You may step through Door 1 to State B, or Door 2 to State C. You cannot phase through the brick wall into State Q just because the product manager had a sudden vision in the shower.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      A Context Payload.
    &lt;/strong&gt;
     The baggage the traveler carries. The pull request number, the customer’s delivery address, the current temperature in Minneapolis. The luggage travels with the workflow, but it does 
    &lt;b&gt;
      not
    &lt;/b&gt;
     drive the car.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Invariants Governed Over Time.
    &lt;/strong&gt;
     Rules that do not bend, no matter how hard the wind blows or how loud the VP of Sales screams into the receiver.
  &lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;
  If that list sounds familiar, it should. That is the formal definition of an extended Finite State Automaton. It was worked out back when computers were the size of municipal water heaters and programmers wore ties to work. 
&lt;/p&gt;&lt;p&gt;
  Yet, for reasons that keep therapists and cloud vendors in business, the modern software industry spends billions of dollars pretending this identity does not exist.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  How We Murder State in the Dark
&lt;/h2&gt;&lt;p&gt;
  The average engineering team approaches a workflow the way a cheap criminal approaches a bank vault: without a blueprint, armed with a crowbar, and hoping nobody asks hard questions until the money is spent.
&lt;/p&gt;&lt;p&gt;
  There are three classic ways this crime is committed in modern codebases.
&lt;/p&gt;&lt;h3&gt;
  ① The Polite Script (The Straight-Line Delusion)
&lt;/h3&gt;&lt;p&gt;
  The engineer begins with a pipeline. In Elixir, they write:
&lt;/p&gt;&lt;p&gt;
  It looks elegant. It reads like poetry written by a civil servant. And it is completely divorced from the physical universe.
&lt;/p&gt;&lt;p&gt;
  In the real world, the credit card is maxed out. In the real world, the warehouse is on fire. In the real world, the customer changed their mind five minutes after clicking the button and wants their money back while the parcel is sitting on a loading dock in New Jersey. 
&lt;/p&gt;&lt;p&gt;
  The moment reality kicks the door down, the clean pipe fractures into a sprawling tangle of nested 
&lt;code class=&quot;code-inline&quot;&gt;case&lt;/code&gt;
   statements, early returns, and rescue blocks. What was supposed to be a straight highway becomes a snake pit.
&lt;/p&gt;&lt;h3&gt;
  ② The Database Stew (Status-Driven Denial)
&lt;/h3&gt;&lt;p&gt;
  When the script breaks, the team “architects” a database solution. They take a PostgreSQL table and give it a 
&lt;code class=&quot;code-inline&quot;&gt;status&lt;/code&gt;
   column. 
&lt;/p&gt;&lt;p&gt;
  Then they realize an order can be paid for, but pending fraud review. So they add 
&lt;code class=&quot;code-inline&quot;&gt;fraud_review_pending: boolean&lt;/code&gt;
  . Then someone discovers an order can be partially refunded while in transit, so they add 
&lt;code class=&quot;code-inline&quot;&gt;partially_refunded: boolean&lt;/code&gt;
   and 
&lt;code class=&quot;code-inline&quot;&gt;transit_hold: boolean&lt;/code&gt;
  .
&lt;/p&gt;&lt;p&gt;
  Within six months, the schema looks like an uninspected electrical switchboard:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup elixir&quot;&gt;schema &amp;quot;workflows&amp;quot; do
  field :status, :string, default: &amp;quot;pending&amp;quot;
  field :paid, :boolean, default: false
  field :reviewed, :boolean, default: false
  field :shipped, :boolean, default: false
  field :cancelled, :boolean, default: false
  field :refunded, :boolean, default: false
  field :on_hold, :boolean, default: false
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Count the representable combinations. Seven statuses multiplied by six boolean flags gives you hundreds of distinct states your database will swallow without blinking. 
&lt;/p&gt;&lt;p&gt;
  Of those hundreds, perhaps six correspond to something that could happen to a physical object without breaking the laws of thermodynamics. The other hundreds are ghosts: orders that are simultaneously 
&lt;code class=&quot;code-inline&quot;&gt;shipped: true&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;paid: false&lt;/code&gt;
  , and 
&lt;code class=&quot;code-inline&quot;&gt;cancelled: true&lt;/code&gt;
  . Rows like that sit in your database like counterfeit bills in a cash register—silent, illegal, and waiting to ruin someone’s morning when the audit comes due.
&lt;/p&gt;&lt;h3&gt;
  ③ The Amnesiac Message Queue
&lt;/h3&gt;&lt;p&gt;
  The third approach is the microservice special. The team splits the workflow into six workers listening to a message bus. 
&lt;/p&gt;&lt;p&gt;
  Worker A does a job, writes to the database, and emits an event. Worker B reads the event, reads the database, checks five conditional flags, does another job, and emits another event.
&lt;/p&gt;&lt;p&gt;
  Nobody has a map of the process. The entire lifecycle is smeared across twenty-four Git repositories like grease on a diner counter. To answer a simple question—
  &lt;b&gt;
    “What happens if the customer cancels while we are waiting for the courier?”
  &lt;/b&gt;
  —you need to interview four engineers, read twelve YAML files, and consult an astrologer.
&lt;/p&gt;&lt;p&gt;
  There is a better way. In fact, there has always been a better way. Let’s look at three real-life cases from the desk blotter.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  Case File: The Task Lifecycle (The Finite Trudge)
&lt;/h2&gt;&lt;p&gt;
  Consider the most familiar trudge in our corner of the city: moving a feature from an engineer’s brain to production. 
&lt;/p&gt;&lt;p&gt;
  The PM thinks it’s a line: 
  &lt;b&gt;
    state task → code → test → commit → review → merge.
  &lt;/b&gt;
&lt;/p&gt;&lt;p&gt;
  Anyone who has survived a single sprint knows that line is a lie. Real engineering is full of hairpin turns and backward loops.
&lt;/p&gt;&lt;p&gt;
  Here is what the lifecycle actually looks like when you shine a hard light on it:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;mermaid lang-mermaid&quot;&gt;stateDiagram-v2
    [*] --&amp;gt; Stated: define_requirements
    Stated --&amp;gt; InDevelopment: pick_up
    InDevelopment --&amp;gt; Committed: run_tests_and_commit
    Committed --&amp;gt; UnderReview: open_pull_request
    UnderReview --&amp;gt; InDevelopment: request_changes
    UnderReview --&amp;gt; Approved: lgtm
    Approved --&amp;gt; Merged: merge_to_trunk
    Merged --&amp;gt; [*]
    
    Stated --&amp;gt; Abandoned: discard
    InDevelopment --&amp;gt; Abandoned: discard
    UnderReview --&amp;gt; Abandoned: discard
    Approved --&amp;gt; Abandoned: discard
    Abandoned --&amp;gt; [*]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Notice what is happening here:
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Review Loop is First-Class.
    &lt;/strong&gt;
     When the senior engineer leaves twenty-seven comments asking why you didn&amp;#39;t use 
&lt;code class=&quot;code-inline&quot;&gt;Enum.reduce_while/3&lt;/code&gt;
    , you don’t crash the pipeline or manufacture an error state. You transition from 
&lt;code class=&quot;code-inline&quot;&gt;UnderReview&lt;/code&gt;
     back to 
&lt;code class=&quot;code-inline&quot;&gt;InDevelopment&lt;/code&gt;
     on the event 
&lt;code class=&quot;code-inline&quot;&gt;request_changes&lt;/code&gt;
    . It is an expected, legitimate transition.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Eject Button Exists Everywhere.
    &lt;/strong&gt;
     A task can be discarded from almost any stage. Marketing can cancel the initiative while you are halfway through writing the tests. If you model this with explicit states, 
&lt;code class=&quot;code-inline&quot;&gt;Abandoned&lt;/code&gt;
     is a clean terminal state that frees resources and logs the autopsy. If you model it with flags, you end up with orphaned branches and pull requests that sit open until the heat death of the universe.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Illegal Moves Are Inconceivable.
    &lt;/strong&gt;
     Can you merge a task directly from 
&lt;code class=&quot;code-inline&quot;&gt;InDevelopment&lt;/code&gt;
     without an approval? Not unless there is an arrow connecting them. The machine does not ask you to remember the policy; the topology 
    &lt;b&gt;
      is
    &lt;/b&gt;
     the policy.
  &lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;h2&gt;
  Case File: Order Fulfillment (The Transactional Gauntlet)
&lt;/h2&gt;&lt;p&gt;
  Now take commerce. An order is placed, money changes hands, goods move across the map.
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;mermaid lang-mermaid&quot;&gt;stateDiagram-v2
    [*] --&amp;gt; Placed: checkout
    Placed --&amp;gt; Authorized: authorize_payment
    Placed --&amp;gt; Failed: payment_declined
    
    Authorized --&amp;gt; Packing: claim_inventory
    Authorized --&amp;gt; Cancelled: cancel_order
    
    Packing --&amp;gt; Shipped: dispatch_courier
    Packing --&amp;gt; Cancelled: inventory_shortage
    
    Shipped --&amp;gt; Delivered: confirm_receipt
    Shipped --&amp;gt; LostInTransit: carrier_lost
    
    Delivered --&amp;gt; Refunded: return_items
    Cancelled --&amp;gt; Refunded: refund_authorization
    
    Failed --&amp;gt; [*]
    LostInTransit --&amp;gt; [*]
    Refunded --&amp;gt; [*]
    Delivered --&amp;gt; [*]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Look closely at the branches:
&lt;/p&gt;&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;
      You cannot refund what was never authorized.
    &lt;/strong&gt;
     In the old boolean stew, a bug in the webhook consumer could mark an order as 
&lt;code class=&quot;code-inline&quot;&gt;refunded: true&lt;/code&gt;
     even if 
&lt;code class=&quot;code-inline&quot;&gt;paid: false&lt;/code&gt;
    . In an FSM, 
&lt;code class=&quot;code-inline&quot;&gt;:refunded&lt;/code&gt;
     can only be reached from states where money was actually held or captured (
&lt;code class=&quot;code-inline&quot;&gt;Authorized&lt;/code&gt;
    , 
&lt;code class=&quot;code-inline&quot;&gt;Delivered&lt;/code&gt;
    , 
&lt;code class=&quot;code-inline&quot;&gt;Cancelled&lt;/code&gt;
    ).
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Cancellation semantics change based on state.
    &lt;/strong&gt;
     If the customer cancels while in 
&lt;code class=&quot;code-inline&quot;&gt;Placed&lt;/code&gt;
    , it costs nothing. If they cancel while in 
&lt;code class=&quot;code-inline&quot;&gt;Packing&lt;/code&gt;
    , the warehouse has to restock the shelf. If they cancel while in 
&lt;code class=&quot;code-inline&quot;&gt;Shipped&lt;/code&gt;
    , you can&amp;#39;t cancel at all—they have to wait for delivery and initiate a return. An FSM makes these rules self-evident because each state only listens to the events that make sense for its location.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Dead ends are accounted for.
    &lt;/strong&gt;
     Packages get lost (
&lt;code class=&quot;code-inline&quot;&gt;LostInTransit&lt;/code&gt;
    ). Cards get declined (
&lt;code class=&quot;code-inline&quot;&gt;Failed&lt;/code&gt;
    ). These are not unhandled exceptions; they are terminal states.
  &lt;/li&gt;
&lt;/ol&gt;&lt;hr/&gt;&lt;h2&gt;
  Case File: The Eternal Beat Cop (The Infinite Loop)
&lt;/h2&gt;&lt;p&gt;
  Some people think workflows have to terminate—that if a process doesn&amp;#39;t reach an end state, it’s defective, like a watch with no hands.
&lt;/p&gt;&lt;p&gt;
  They haven&amp;#39;t spent enough time watching the street. Half the machinery keeping the modern world turning runs in an infinite, unblinking loop: kiosks, telemetry collectors, sensor monitors, background pollers.
&lt;/p&gt;&lt;p&gt;
  Take a classic: 
  &lt;strong&gt;
    receive a weather forecast → show it → wait fifteen minutes → repeat.
  &lt;/strong&gt;
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;mermaid lang-mermaid&quot;&gt;stateDiagram-v2
    [*] --&amp;gt; Fetching: init
    Fetching --&amp;gt; Displaying: forecast_received
    Fetching --&amp;gt; Backoff: network_error
    
    Backoff --&amp;gt; Fetching: retry_timer
    
    Displaying --&amp;gt; Waiting: render_complete
    Waiting --&amp;gt; Fetching: refresh_timer&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  A novice looks at that loop and gets nervous. 
  &lt;b&gt;
    “Won’t the stack overflow? Won’t the memory swell up like a drowned corpse?”
  &lt;/b&gt;
&lt;/p&gt;&lt;p&gt;
  In a traditional imperative language with recursive calls or unchecked event listeners, quite possibly. But in an honest, process-backed finite-state machine (such as an OTP GenServer running under 
  &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;
&lt;code class=&quot;code-inline&quot;&gt;Finitomata&lt;/code&gt;
  &lt;/a&gt;
  ):
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    &lt;strong&gt;
      The Stack Never Grows.
    &lt;/strong&gt;
     State transitions are tail-recursive loops or GenServer callback returns (
&lt;code class=&quot;code-inline&quot;&gt;{:ok, :displaying, payload}&lt;/code&gt;
    ). The machine sheds its past with every step. It can cycle two billion times without consuming an additional byte of RAM.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Time Is Just Another Event.
    &lt;/strong&gt;
     Notice 
&lt;code class=&quot;code-inline&quot;&gt;refresh_timer&lt;/code&gt;
     and 
&lt;code class=&quot;code-inline&quot;&gt;retry_timer&lt;/code&gt;
    . Time is not a rogue cron job breaking in through the fire escape and mutating rows in your database behind your back. Time is an internal event generated by the machine itself.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;
      Self-Healing Is Built-In.
    &lt;/strong&gt;
     When the weather API returns a 503 Gateway Timeout, the machine doesn&amp;#39;t throw a fit and die; it slips into the 
&lt;code class=&quot;code-inline&quot;&gt;Backoff&lt;/code&gt;
     state, sets a short timer, and tries again. 
  &lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;
  It is the eternal beat cop walking the neighborhood: check the door, see it&amp;#39;s locked, wait under the streetlamp, walk the block again. Rain or shine, day or night.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Detective’s Rules: How to Model a Workflow Properly
&lt;/h2&gt;&lt;p&gt;
  If you want your workflows to hold up when the district attorney starts asking questions, you need discipline. Here are the rules I live by:
&lt;/p&gt;&lt;h3&gt;
  ① The Blueprint Precedes the Brick
&lt;/h3&gt;&lt;p&gt;
  Before you write a single line of business logic, declare the topology. If you cannot draw your workflow on a napkin using simple state-and-event syntax, you do not understand the business problem. 
&lt;/p&gt;&lt;p&gt;
  Using 
&lt;code class=&quot;code-inline&quot;&gt;Finitomata&lt;/code&gt;
  , the declaration is not an afterthought or an export—it is the source code itself:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup elixir&quot;&gt;defmodule WeatherStation.FSM do
  @fsm &amp;quot;&amp;quot;&amp;quot;
  fetching --&amp;gt; |forecast_received| displaying
  fetching --&amp;gt; |network_error| backoff
  backoff --&amp;gt; |retry_timer| fetching
  displaying --&amp;gt; |render_complete| waiting
  waiting --&amp;gt; |refresh_timer| fetching
  &amp;quot;&amp;quot;&amp;quot;

  use Finitomata, fsm: @fsm, timer: 60_000
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  The string above is compiled directly into a validated state machine. If an event arrives that isn&amp;#39;t declared for the current state, it doesn&amp;#39;t corrupt your data; it gets rejected with an explicit error.
&lt;/p&gt;&lt;h3&gt;
  ② State Is the Coordinate; Payload Is the Luggage
&lt;/h3&gt;&lt;p&gt;
  Keep your states small, finite, and enumerable. A state is an atom: 
&lt;code class=&quot;code-inline&quot;&gt;:idle&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:under_review&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:waiting&lt;/code&gt;
  . 
&lt;/p&gt;&lt;p&gt;
  The data associated with that state—the reviewer’s notes, the temperature reading, the authorization token—belongs strictly in the 
  &lt;strong&gt;
    payload
  &lt;/strong&gt;
  .
&lt;/p&gt;&lt;p&gt;
  Never, under any circumstances, allow data to disguise itself as a state:
&lt;/p&gt;&lt;p&gt;
  State determines 
  &lt;b&gt;
    what can happen next
  &lt;/b&gt;
  . Payload provides the 
  &lt;b&gt;
    context for how it happens
  &lt;/b&gt;
  . Conflating the two is the root of all evil.
&lt;/p&gt;&lt;h3&gt;
  ③ Events Knock; They Do Not Dictate
&lt;/h3&gt;&lt;p&gt;
  External actors do not change your state. An HTTP request does not get to say 
&lt;code class=&quot;code-inline&quot;&gt;order.set_state(:shipped)&lt;/code&gt;
  . 
&lt;/p&gt;&lt;p&gt;
  External actors emit 
  &lt;strong&gt;
    events
  &lt;/strong&gt;
  . The state machine receives the event, verifies that the transition is legal from its current coordinate, executes the business callback, and moves itself.
&lt;/p&gt;&lt;p&gt;
  If the shipping department sends a 
&lt;code class=&quot;code-inline&quot;&gt;:dispatch&lt;/code&gt;
   event for an order that is still 
&lt;code class=&quot;code-inline&quot;&gt;:placed&lt;/code&gt;
   (unpaid), the machine laughs in their face, logs the attempt, and stays right where it was.
&lt;/p&gt;&lt;h3&gt;
  ④ Failure Is a Destination, Not a Panic
&lt;/h3&gt;&lt;p&gt;
  In cheap dime-store novels, people get hit over the head, lose consciousness, and wake up fine twenty minutes later. In production systems, exceptions don&amp;#39;t work that way. An unhandled exception kills the thread, corrupts state, and leaves the on-call engineer staring at a pile of ash.
&lt;/p&gt;&lt;p&gt;
  Every state that touches an external boundary—a network call, a disk write, a human approval—must account for failure as a first-class transition. Model the failure states: 
&lt;code class=&quot;code-inline&quot;&gt;:payment_declined&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:review_rejected&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;:backoff&lt;/code&gt;
  . Give failure a roof and a bed, and it won&amp;#39;t burn your house down.
&lt;/p&gt;&lt;h3&gt;
  ⑤ One Process, One Life
&lt;/h3&gt;&lt;p&gt;
  If you’re running on the BEAM, every active workflow instance should be its own isolated process. 
&lt;/p&gt;&lt;p&gt;
  Order 
&lt;code class=&quot;code-inline&quot;&gt;#1042&lt;/code&gt;
   gets a GenServer. Pull Request #88 gets a GenServer. Weather Station #4 gets a GenServer.
&lt;/p&gt;&lt;p&gt;
  When an entity is backed by an isolated process:
&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
    Incoming events are naturally serialized in the process mailbox. The concurrency race condition—where two web requests simultaneously attempt to modify the same record—vanishes by design.
  &lt;/li&gt;
  &lt;li&gt;
    If one workflow instance encounters a catastrophic error, it crashes alone, supervised, leaving the other fifty thousand running instances untouched.
  &lt;/li&gt;
  &lt;li&gt;
    State lives in fast, in-memory Erlang terms, backed to disk only at transition boundaries.
  &lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;h2&gt;
  Epilogue: The Long Goodbye to Spaghetti
&lt;/h2&gt;&lt;p&gt;
  The rain had stopped by three in the morning. The streets of the city were black mirrors reflecting the streetlights.
&lt;/p&gt;&lt;p&gt;
  The four-inch design document from the architect was still on my desk, right next to the empty coffee mug. I opened it to page one, took out a red marker, and struck through thirty-seven pages of “dynamic plugin orchestrators” and “configurable JSON execution pipelines.”
&lt;/p&gt;&lt;p&gt;
  In their place, I drew six circles, eight arrows, and a dozen clear labels.
&lt;/p&gt;&lt;p&gt;
  A workflow is not a mystical creature requiring enterprise middleware. It is a finite automaton in a damp trench coat, waiting for you to stop lying to yourself.
&lt;/p&gt;&lt;p&gt;
  Give it a name. Draw its borders. Lock the doors that shouldn&amp;#39;t be opened. You’ll sleep better at night, and when trouble comes knocking at your door—as it always does—you’ll know exactly which state you’re in.
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Aggressive Regression · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/aggressive-regression</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-aggressive-regression.png"></enclosure>
<guid isPermaLink="false">DH6qsyZZYH9txxfViuGz2QqK9dW7-z25PCIO9A==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>There is a particular species of engineer who, upon being asked whether the codebase has regression tests, answers “we have ninety-four percent coverage.” This is roughly equivalent to answering the question “is your house insured against fire?” with “I own eleven smoke detectors, and I polish them weekly.” The two statements inhabit the same general neighbourhood of concern, but they are not even distantly related, and only one of them will matter on the night the kitchen goes up.</description>
<content:encoded>&lt;p&gt;
  There is a particular species of engineer who, upon being asked whether the codebase has regression tests, answers “we have ninety-four percent coverage.” This is roughly equivalent to answering the question “is your house insured against fire?” with “I own eleven smoke detectors, and I polish them weekly.” The two statements inhabit the same general neighbourhood of concern, but they are not even distantly related, and only one of them will matter on the night the kitchen goes up.
&lt;/p&gt;&lt;p&gt;
  Let me state the thesis up front, in the hope that it survives the journey to the comment section intact:
&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;
    &lt;strong&gt;
      Each regression test is a test, but not each test is a regression test.
    &lt;/strong&gt;
  &lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;
  This is not a clever aphorism. It is a statement about sets, and it is the sort of statement that most teams nod along to and then immediately violate for the next four years.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Taxonomy Nobody Bothered to Learn
&lt;/h2&gt;&lt;p&gt;
  A test is any piece of code that fails when something is wrong. A regression test is a piece of code that fails when something that 
  &lt;b&gt;
    used to work
  &lt;/b&gt;
   stops working. The second category is a strict subset of the first, and it is a far smaller subset than your coverage badge implies.
&lt;/p&gt;&lt;p&gt;
  The distinction lives entirely in the word “used.” A regression test is a memorial. It commemorates a specific moment in history when the software was observably correct, and it stands guard over that moment for as long as the project draws breath. It is, in the most literal sense, institutional memory encoded as an executable artifact—which makes it the only kind of memory your team has that does not resign, retire, or forget everything over a long weekend.
&lt;/p&gt;&lt;p&gt;
  Most of what we write is not that. Most of what we write is a developer reassuring himself, at two in the afternoon, that the function he just typed does what he just thought it should do while typing it. That is a perfectly respectable activity. It is also a tautology wearing a lab coat.
&lt;/p&gt;&lt;h2&gt;
  Unit Tests Are Mostly Mirrors
&lt;/h2&gt;&lt;p&gt;
  Here is the uncomfortable thing about the average unit test: it asserts that the implementation does what the implementation does. The author wrote the function, then wrote a test derived from the function he had just written, and then expressed surprise when both agreed. The two of them were never going to disagree. They were conceived in the same five minutes, by the same brain, operating under the same misconception. Even a mediocre developer like myself is able to wrote a simple function without a glitch.
&lt;/p&gt;&lt;p&gt;
  A unit test typically reaches inside. It knows the module’s private helpers, the shape of its intermediate accumulator, the exact order in which it walks a list. It is intimate with details that no caller will ever observe, and it is therefore brittle in exactly the places where you most want freedom to move. Refactor the accumulator from a list to a map, change nothing whatsoever about the observable behaviour, and watch forty tests go red while the software remains flawless. That is not a safety net. That is a tripwire you installed in your own hallway.
&lt;/p&gt;&lt;p&gt;
  And the whole edifice rests on the author’s imagination. The unit test checks the three inputs he thought of. The bug, with the unerring instinct of a cat finding the one guest in the room who is allergic, lives in the fourth.
&lt;/p&gt;&lt;p&gt;
  This is why a single property-based test is worth a bushel of hand-typed examples. Instead of enumerating what you happened to think of, you declare the law the function must obey, and then let the machine hunt for the counterexample with a diligence no human possesses at four in the afternoon on a Friday.
&lt;/p&gt;&lt;p&gt;
  Consider the usual hand-rolled approach:
&lt;/p&gt;&lt;p&gt;
  Three assertions, all of them chosen by a human being who already knew how the encoder worked. Compare:
&lt;/p&gt;&lt;p&gt;
  One statement, infinitely many assertions, and—this is the part people underestimate—an automatic shrinker that will hand you the 
  &lt;b&gt;
    minimal
  &lt;/b&gt;
   failing input rather than the 4 KiB blob that happened to trip it. The first version tests your memory. The second tests your software.
&lt;/p&gt;&lt;p&gt;
  The law, incidentally, is the valuable artifact here. 
&lt;code class=&quot;code-inline&quot;&gt;decode(encode(x)) == x&lt;/code&gt;
   is a sentence about the system that remains true across three rewrites, two language versions, and the departure of everyone who originally wrote it. Your 
&lt;code class=&quot;code-inline&quot;&gt;assert &amp;quot;abc&amp;quot; == ...&lt;/code&gt;
   is true about one string.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Round-trips, idempotency, commutativity, invariants, monotonicity, order-independence.
  &lt;/strong&gt;
   If you cannot name a single law your module obeys, that is not an argument against property-based testing. That is a code review finding about the module.
&lt;/p&gt;&lt;h2&gt;
  Elixir Will Not Let You Test Private Functions, and This Is a Feature
&lt;/h2&gt;&lt;p&gt;
  Every few months, someone arrives in the forums with a question phrased as a bug report: “How do I test a 
&lt;code class=&quot;code-inline&quot;&gt;defp&lt;/code&gt;
  ?” The replies are patient and the asker is rarely satisfied, because the honest answer sounds like obstruction: 
  &lt;b&gt;
    you don’t, and you shouldn’t want to.
  &lt;/b&gt;
&lt;/p&gt;&lt;p&gt;
  A private function is, by construction, not part of your contract. It is scaffolding. It exists because you got tired of a forty-line function and cut it in half, or because you needed the same three lines twice. Nobody outside the module can call it, nobody outside the module can observe it, and nobody outside the module can be harmed by its replacement. It has no behaviour in the sense that matters—it has only implementation, and implementation is precisely the thing we have just agreed not to test.
&lt;/p&gt;&lt;p&gt;
  The language is not being coy. It is telling you something: if you feel a burning need to test a private function in isolation, you have discovered a design pressure, not a tooling gap. One of three things is true.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    It should be public.
  &lt;/strong&gt;
   The thing you are trying to pin down is actually part of the module’s contract, and you have been hiding your own API from yourself out of some misplaced sense of tidiness. Make it public, document it, test it as the public thing it always was.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    It should be its own module.
  &lt;/strong&gt;
   The private helper has grown a personality: its own vocabulary, its own invariants, its own reasons to change. That is a module trying to be born. Deliver it. Now it has a public interface, and testing it is no longer a philosophical crisis.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    It should not be tested at all.
  &lt;/strong&gt;
   It is three lines of plumbing between two public functions, both of which are tested, and both of which would scream if the plumbing broke. Leave it alone. Every test you write is a line of code you must maintain forever; spending that budget on a private two-liner is the testing equivalent of insuring your shoelaces.
&lt;/p&gt;&lt;p&gt;
  Naturally, someone always discovers that 
&lt;code class=&quot;code-inline&quot;&gt;Module.function&lt;/code&gt;
   can be reached through the back door with 
&lt;code class=&quot;code-inline&quot;&gt;apply/3&lt;/code&gt;
   and a little dishonesty about module attributes, or that one can simply 
&lt;code class=&quot;code-inline&quot;&gt;@compile :export_all&lt;/code&gt;
   and get on with their life. Yes. You can also remove the guard rail from the balcony because it was obstructing your view. The guard rail was not a design flaw.
&lt;/p&gt;&lt;p&gt;
  There is a second, subtler gift hidden here. Because the language makes implementation untestable, it quietly pushes the lazy developer—and we are all the lazy developer on Thursday afternoons—toward testing behaviour, which is the only thing worth testing. Elixir has encoded good taste into a compiler restriction. Few languages have the nerve.
&lt;/p&gt;&lt;h2&gt;
  Behaviour Is a Virtue, Implementation Is a Vice
&lt;/h2&gt;&lt;p&gt;
  Here is the mechanical reason behavioural tests catch regressions and implementation tests do not.
&lt;/p&gt;&lt;p&gt;
  A regression happens when someone changes the implementation and accidentally changes the behaviour. That is the entire anatomy of the bug. Now ask yourself what happens to each kind of test under that event.
&lt;/p&gt;&lt;p&gt;
  The implementation test was written against the 
  &lt;b&gt;
    old
  &lt;/b&gt;
   implementation. The developer changed the implementation, so the test broke—but it broke because the implementation changed, not because anything is wrong. The developer, who is reasonable and in a hurry and has seen this test cry wolf nine times this month, updates the test to match the new implementation. The test goes green. The bug ships. The test participated enthusiastically in its own defeat.
&lt;/p&gt;&lt;p&gt;
  The behavioural test was written against the contract, which nobody intended to change. It breaks, and there is no plausible way to “update” it, because updating it would mean openly declaring that the software now does something different. That is a conversation, not a quick fix. The bug does not ship.
&lt;/p&gt;&lt;p&gt;
  This is why a proper regression test has a very particular pedigree. It is not written during feature development by a cheerful developer. It is written in the aftermath of an incident, by someone slightly grim, and it reads like a deposition:
&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup elixir&quot;&gt;describe &amp;quot;issue #1472: timezone-aware expiry&amp;quot; do
  test &amp;quot;a token minted at 23:59 UTC is not considered expired at 00:01 local&amp;quot; do
    # Reported 2026-04-02 by a customer in UTC+2 whose entire
    # team was logged out overnight, every night, for nine days.
    token = mint_token(at: ~U[2026-04-02 23:59:00Z], ttl: {2, :hour})
    assert {:ok, _claims} = verify(token, now: ~U[2026-04-03 00:01:00Z])
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  Note what the test knows and what it does not. It knows the symptom, the date, and the victim. It does not know whether expiry is computed with 
&lt;code class=&quot;code-inline&quot;&gt;DateTime.diff/3&lt;/code&gt;
  , a cached monotonic offset, or a sundial. You may rewrite the entire expiry subsystem tomorrow; this test will follow you there without complaint, and the day it goes red, something real has broken.
&lt;/p&gt;&lt;p&gt;
  Note also the comment. A regression test without the story of its birth is a landmine for the next person, who will eventually look at a strange assertion, fail to imagine why anyone would write it, and delete it. Every regression test should carry its own obituary of the bug it buried. The cheapest documentation in your repository is the kind that fails the build when it becomes false.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    The rule, stated without hedging: every bug fix is accompanied by a test that fails before the fix and passes after it.
  &lt;/strong&gt;
   If you cannot write a failing test, you have not understood the bug—you have merely disturbed it, and it will be back.
&lt;/p&gt;&lt;h2&gt;
  And Then the Machines Arrived
&lt;/h2&gt;&lt;p&gt;
  Everything above has been true since roughly 1975. What changed is the volume and the provenance of the code flowing into the repository.
&lt;/p&gt;&lt;p&gt;
  Generated code is not worse than human code in the way the pessimists claim. It is worse in a far more specific and dangerous way: it is 
  &lt;b&gt;
    locally plausible and globally unanchored
  &lt;/b&gt;
  . A model producing a refactor has no memory of the incident in April. It has never been paged at three in the morning. It does not know that the retry loop has a weird 
&lt;code class=&quot;code-inline&quot;&gt;+1&lt;/code&gt;
   in it because a vendor’s API counts from one, nor that the normalisation step strips a particular Unicode character because of a bug in a client shipped four years ago that six hundred customers still run. All of that knowledge lives in exactly two places: in the heads of people who will eventually leave, and in your regression suite.
&lt;/p&gt;&lt;p&gt;
  When a human engineer stumbles across a strange-looking line, he hesitates. He runs 
&lt;code class=&quot;code-inline&quot;&gt;git blame&lt;/code&gt;
  . He asks in the channel. A model does not hesitate—it cannot, because hesitation requires knowing what you do not know, and a statistical amplifier is constitutionally incapable of that particular form of humility. It sees an oddity, recognises it as deviation from the idiom it was trained on, and tidies it away with the serene confidence of a cleaner throwing out an artist’s installation because it looked like a pile of rubbish.
&lt;/p&gt;&lt;p&gt;
  Your regression suite is the only thing in the building that can say 
  &lt;b&gt;
    no
  &lt;/b&gt;
   to that. Not the linter, which thinks the tidied version is objectively nicer. Not the type checker, which agrees the types line up. Not the reviewer, who is looking at a diff of four hundred lines with a meeting in six minutes. Only the test that was written by someone who was there in April.
&lt;/p&gt;&lt;p&gt;
  Which inverts the economics in a way worth stating plainly. The cost of 
  &lt;b&gt;
    writing
  &lt;/b&gt;
   code has collapsed. The cost of 
  &lt;b&gt;
    verifying
  &lt;/b&gt;
   code has not moved an inch, because verification is where the domain knowledge lives, and the domain knowledge was never in the keystrokes. So the ratio shifts. In 2015, a team producing ten thousand lines a year could afford a casual relationship with its test suite. A team now producing ten thousand lines a month cannot—not because the code is worse, but because no human being is reading all of it any more, and the suite has quietly been promoted from a safety net to the primary reader.
&lt;/p&gt;&lt;p&gt;
  Two corollaries, both of which people hate.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Never let the model write the regression test from the fixed code.
  &lt;/strong&gt;
   The whole point of the test is that it was derived from the 
  &lt;b&gt;
    symptom
  &lt;/b&gt;
  , independently of the implementation. Hand the model a patched function and ask for tests, and you will get an exquisitely thorough description of the patch—a mirror again, just a faster and better-spelled one. Write the failing test first, from the bug report, in your own words. 
  &lt;b&gt;
    Then
  &lt;/b&gt;
   let the machine fix the code until the test goes green. That direction is enormously productive. The other direction is theatre.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Be very suspicious of a green suite after a large generated refactor.
  &lt;/strong&gt;
   Either your suite is genuinely behavioural, in which case congratulations and go to lunch; or your suite is thin enough that nothing in it was ever going to notice. The way to tell the two apart is to break something on purpose and check that the suite screams. If you want this done systematically rather than by intuition, that is what mutation testing is for: it is a test suite for your test suite, and the first run is always a humbling experience. Try 
  &lt;a href=&quot;https://muex.hexdocs.pm&quot;&gt;
&lt;code class=&quot;code-inline&quot;&gt;muex&lt;/code&gt;
  &lt;/a&gt;
   btw, it shines.
&lt;/p&gt;&lt;h2&gt;
  Further Specimens from the Wild
&lt;/h2&gt;&lt;p&gt;
  Since the invitation was open, some additional patterns that live on the correct side of the line—and some impostors that do not.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Characterisation tests for code nobody understands.
  &lt;/strong&gt;
   You inherit forty thousand lines with no tests and no author. You cannot write behavioural tests, because nobody knows the intended behaviour. So you write down the 
  &lt;b&gt;
    actual
  &lt;/b&gt;
   behaviour—feed it real inputs, record the real outputs, assert they stay that way. The tests are not asserting correctness; they are asserting 
  &lt;b&gt;
    stability
  &lt;/b&gt;
  , which is the only thing you can honestly assert about a system you do not understand. This is regression testing in its purest form, and it is the standard entry point for making a legacy system safe to touch. Mark these tests clearly, because some of them are faithfully memorialising bugs, and one day somebody will fix one and be baffled by the red.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Doctests as behavioural contracts.
  &lt;/strong&gt;
   Elixir’s 
&lt;code class=&quot;code-inline&quot;&gt;doctest&lt;/code&gt;
   is a quietly radical idea: the example in your documentation 
  &lt;b&gt;
    is
  &lt;/b&gt;
   the test. Documentation lies constantly, in every project, in every language—except here, where a lie fails the build. And because documentation examples are written from the caller’s perspective by definition, they are behavioural by construction. You cannot document a private function’s intermediate accumulator in a way a user would find useful, so you do not, so you do not test it. The medium enforces the message.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Contract tests at service boundaries.
  &lt;/strong&gt;
   The regression most likely to ruin your quarter is not in your code at all; it is in the thing on the other end of the wire, which changed a field from a string to an integer on a Tuesday without telling anyone. A contract test pinned against a recorded, versioned interaction catches this on your CI run rather than in production. Behaviour at the seam, which is exactly where behaviour is hardest to reason about and most expensive to get wrong.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
&lt;code class=&quot;code-inline&quot;&gt;Mox&lt;/code&gt;
    and explicit contracts, as opposed to mocks as a noun.
  &lt;/strong&gt;
   Mocking a private collaborator so you can assert it was called twice is implementation testing with extra ceremony and a worse error message. Defining a behaviour, implementing it twice—once for real, once for tests—and asserting on the 
  &lt;b&gt;
    interaction contract
  &lt;/b&gt;
   is behavioural testing with a swappable backend. The first makes refactoring harder; the second makes it possible. Same library, opposite outcomes, and the difference is entirely in whether the thing you mocked is a contract or an accident.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    The flaky test is a regression test for your concurrency, and you are ignoring it.
  &lt;/strong&gt;
   Every team has the one test that fails on CI roughly once a fortnight, and every team has a 
&lt;code class=&quot;code-inline&quot;&gt;@tag :skip&lt;/code&gt;
   or a retry wrapper over it. That test is not broken. That test is the only member of your suite that has noticed the race condition, and you have gagged it. Nondeterminism in tests is nearly always a true positive about nondeterminism in the system.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Time, locale, and encoding: the three horsemen.
  &lt;/strong&gt;
   A startling proportion of all regressions ever recorded come from a date crossing a boundary, a locale formatting a number differently, or a string containing a character the author did not personally know existed. These are behavioural by nature and almost never covered by hand-written unit tests, because the author’s imagination is bounded by the author’s timezone, locale, and alphabet. Property-based generators do not share those limitations, which is why 
&lt;code class=&quot;code-inline&quot;&gt;StreamData&lt;/code&gt;
   finds the emoji in the username field within about nine runs.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Golden files, used correctly.
  &lt;/strong&gt;
   Snapshot testing has a bad reputation, entirely earned by teams who run “update all snapshots” as a reflex and commit the result unread. A golden file reviewed with the same seriousness as source code is an excellent regression test for anything with a complex output shape—rendered templates, generated schemas, serialised payloads. A golden file updated by muscle memory is a 
&lt;code class=&quot;code-inline&quot;&gt;git add .&lt;/code&gt;
   with extra steps.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    Performance budgets.
  &lt;/strong&gt;
   A function that returns the correct answer in nine seconds, having previously taken forty milliseconds, has regressed—the behaviour of a system includes how long it takes to behave. A crude assertion on an upper bound, or a benchmark gated in CI, catches the accidental 
&lt;code class=&quot;code-inline&quot;&gt;Enum.map&lt;/code&gt;
   over a database query that someone slipped into a loop. Nobody writes these until the first outage, which is a pity, because they are cheap.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    The test you delete.
  &lt;/strong&gt;
   Last and most heretical: a test that has never failed for a good reason in three years is not protecting you. It is a line item in your maintenance budget, a drag on your CI time, and a small daily tax on everyone’s attention. Regression suites should grow by accretion from real incidents, not by a coverage quota imposed from above. Coverage measures which lines were executed, not which behaviours were verified—a suite that calls every function and asserts nothing scores a perfect hundred, as does one that asserts everything and understands nothing.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Short Version
&lt;/h2&gt;&lt;p&gt;
  Tests are cheap and plentiful; regression tests are expensive and rare, because each one costs an actual outage to acquire. Treat them accordingly. Write them from the bug report rather than from the fix. Aim them at the contract rather than the plumbing. Let the compiler stop you from testing private functions, and thank it quietly for the discipline. Prefer the law to the example, and let the generator find the counterexample you were never going to think of.
&lt;/p&gt;&lt;p&gt;
  And now that an increasing share of your codebase is arriving from a source with no memory of the last five years, understand what your suite has become. It is not a quality gate any more. It is the only institutional memory you have that does not sleep, does not leave, and cannot be talked out of its position by a confident paragraph of plausible reasoning.
&lt;/p&gt;&lt;p&gt;
  Be aggressive about it. The regressions certainly will be.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Previously, on the subject of not deceiving yourself with your own tooling:
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>The Engine Does Not Win the Race · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/the-engine-does-not-win-the-race</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-the-engine-does-not-win-the-race.png"></enclosure>
<guid isPermaLink="false">7GbS0lI-hpG_gOX3bl0PZhOpK-6RLZsTna2e_w==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>Somewhere, right now, a senior engineer is on the verge of a nervous breakdown because his company will not let him switch from Claude to GPT-5.2, or from GPT to Gemini, or from any of them to a locally-hosted llama that smells faintly of the intern’s vape juice. He is convinced that the model is the product, that the difference between shipping and slop is measured in the number of parameters, and that if only management would approve the more expensive API key, all his problems would evap...</description>
<content:encoded>&lt;p&gt;
  Somewhere, right now, a senior engineer is on the verge of a nervous breakdown because his company will not let him switch from Claude to GPT-5.2, or from GPT to Gemini, or from any of them to a locally-hosted llama that smells faintly of the intern’s vape juice. He is convinced that the model is the product, that the difference between shipping and slop is measured in the number of parameters, and that if only management would approve the more expensive API key, all his problems would evaporate like a hallucinated citation.
&lt;/p&gt;&lt;p&gt;
  He is wrong. Spectacularly, expensively, and in a way that will cost him exactly the difference between the two API tiers he is agonising over.
&lt;/p&gt;&lt;p&gt;
  Allow me to explain, in the manner of a man who has spent the last year building the thing he is about to tell you matters more than the model itself.
&lt;/p&gt;&lt;hr/&gt;&lt;h2&gt;
  The Engine Is a Fish With Amnesia
&lt;/h2&gt;&lt;p&gt;
  Let us first agree on what a language model actually is, because ninety percent of the industry’s agonising proceeds from a category error. A language model is a finite state machine with the memory span of a goldfish that has been through electroconvulsive therapy. Its entire operation collapses into the unglamorous sequence:
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;start → context → query → response → stop&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  There are no sessions. There is no memory. Every single request you make begins from a blank slate, and the model dutifully autocompletes whatever you have managed to cram into the context window in that one trembling moment. The model does not know your codebase, your conventions, your catastrophic past decisions, or even that it spoke to you thirty seconds ago. It knows only the distillate you present it with—and, being a statistical amplifier of whatever it is fed, it will faithfully amplify that distillate into either gold or guano.
&lt;/p&gt;&lt;p&gt;
  Here is the uncomfortable corollary that nobody in the model-comparison industry wants to hear: 
  &lt;strong&gt;
    the model is the least interesting component of the entire stack.
  &lt;/strong&gt;
   The interesting component is the thing that decides what goes into that context window, in what order, at what cost, and with what guard-rails around the output. That thing is the harness. And the harness is where the entire difference between “Dull ∧ LLM = Dull” and “Wise ∧ LLM = Wise²” actually lives.
&lt;/p&gt;&lt;p&gt;
  I wrote that truth table once, in a fit of optimism, and immediately got accused of supporting both Trump and Putin by people whose reading comprehension caps out at three words per paragraph. So let me be plainer this time. The model is the engine. Nobody drives an engine. You drive a car—and the car is the harness.
&lt;/p&gt;&lt;h2&gt;
  Nobody Buys a Car for the Crankshaft
&lt;/h2&gt;&lt;p&gt;
  Any fool can bolt a thousand-horsepower engine into a shopping trolley. It will do a spectacular burnout for exactly one second before the steering shears off, the brakes fade to nothing, and the whole contraption wraps itself around a lamppost with the shopping basket still full of the groceries it was never going to deliver. Horsepower is the number that sells the brochure; the transmission, the suspension, the brakes, and the fuel injection are what decide whether you actually arrive anywhere.
&lt;/p&gt;&lt;p&gt;
  The industry has this exactly backwards. We have spent three years obsessing over which engine to buy while ignoring the fact that most of these engines are being bolted into shopping trolleys—single-threaded Node.js scripts that crash the instant a tool call returns an unexpected byte, wipe the entire conversation, and force you to re-explain your whole codebase to a model that has already forgotten you exist.
&lt;/p&gt;&lt;p&gt;
  And here is the cruelest part: 
  &lt;strong&gt;
    the engine does not even know it is in a shopping trolley.
  &lt;/strong&gt;
   It cannot feel the lamppost approaching. It will cheerfully keep generating code until the trolley is a smoking ruin, because generating code is all it has ever been trained to do. The harness is the only component with both the information and the authority to say “no.”
&lt;/p&gt;&lt;p&gt;
  So what does a proper harness actually do? Two things, and only two things, and both of them are worth more than any model upgrade you are currently begging your CTO for. It decides what the model sees. And it decides what the model is allowed to do with what it sees. Everything else is decoration.
&lt;/p&gt;&lt;h2&gt;
  What the Model Sees: The Miser’s Art of Context
&lt;/h2&gt;&lt;p&gt;
  The single most expensive resource in this entire economy is not compute, not GPUs, not even the API bill—it is the context window, and the tokens you waste filling it with noise. Every model has a hard ceiling on how much it can attend to before quality collapses into a puddle of generic autocomplete, and that ceiling is a great deal lower than the marketing department would have you believe. Nobody can actually process a hundred thousand tokens without noticeable quality loss. The ones who claim otherwise are selling subscriptions.
&lt;/p&gt;&lt;p&gt;
  This is why the R in RAG matters hundreds of times more than the model itself, and why every serious player has been quietly burrowing into your computer with an IDE or a CLI. Shipping the entire codebase to the server on every request is not just expensive—it is actively harmful, because it drowns the signal in noise. A proper harness does not ship the world. It ships a 
  &lt;b&gt;
    distillate
  &lt;/b&gt;
  : precisely the files, symbols, and idioms the current task requires, nothing more, nothing less.
&lt;/p&gt;&lt;p&gt;
  Let me be concrete, because I built the thing I am about to describe, and I would rather show you the plumbing than wave at the facade. My slowly-simmering harness, 
  &lt;a href=&quot;https://github.com/Oeditus/yoke&quot;&gt;
    Yoke (
&lt;code class=&quot;code-inline&quot;&gt;yoke&lt;/code&gt;
    )
  &lt;/a&gt;
  , spends an almost pathological amount of effort on not wasting your tokens:
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    Selective context expansion.
  &lt;/strong&gt;
   You type 
&lt;code class=&quot;code-inline&quot;&gt;@lib/auth.ex&lt;/code&gt;
   or 
&lt;code class=&quot;code-inline&quot;&gt;@error&lt;/code&gt;
   or even the lazy phrase “error above,” and the harness expands 
  &lt;b&gt;
    only
  &lt;/b&gt;
   that reference into the prompt—fetching the file, the URL, or the relevant error block it has been tracking as open versus resolved. It does not dump your entire repository into the context window on the off-chance that something in there might be relevant. It fetches the one thing you pointed at, and it keeps a running ledger of which issues are still open so that when you say “fix it,” it knows what “it” refers to without you re-pasting the stack trace for the fourth time.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    AST instead of raw text.
  &lt;/strong&gt;
   This is the hill I will die on. Raw source code is a terrible format for a context window—verbose, redundant, and full of formatting noise that contributes nothing to understanding. An abstract syntax tree is structured, compact, and far more legible to a model that was trained on code. My 
  &lt;a href=&quot;https://hexdocs.pm/ragex&quot;&gt;
    Ragex
  &lt;/a&gt;
   integration lets the harness search and present code as structure rather than as a wall of text. Same understanding, a fraction of the tokens. This is not a micro-optimisation; it is the difference between being able to fit a real codebase in the window and being able to fit a single file.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    Compression on demand.
  &lt;/strong&gt;
   When a session has been running long enough that the context window is groaning under its own history, 
&lt;code class=&quot;code-inline&quot;&gt;/compact&lt;/code&gt;
   hands the conversation to the model and asks it to produce a condensed summary preserving the core goal, the files touched, the decisions made, and the next steps. The verbose transcript is then replaced by that summary. The model keeps its memory of 
  &lt;b&gt;
    what matters
  &lt;/b&gt;
   without you paying to retransmit every failed attempt and every “no, I meant the other thing.”
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The miser’s dashboard.
  &lt;/strong&gt;
   
&lt;code class=&quot;code-inline&quot;&gt;/tokens&lt;/code&gt;
   and 
&lt;code class=&quot;code-inline&quot;&gt;/cost&lt;/code&gt;
   show you, per turn and cumulatively, exactly what you are spending. You cannot optimise what you cannot measure, and the token gauge on the status bar is the financial equivalent of a fuel gauge on a car that everybody else is driving blind.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    Sessions that survive death.
  &lt;/strong&gt;
   Every conversation is persisted to disk as a resumable narrative, so if you walk away for the night—or the harness crashes, or the cat walks across the keyboard and closes the terminal—you resume with 
&lt;code class=&quot;code-inline&quot;&gt;yoke -c&lt;/code&gt;
   exactly where you left off. This sounds like a convenience feature. It is actually a token-saving feature of the first order, because the most expensive tokens in any project are the ones you spend re-explaining your codebase to a model that forgot it overnight.
&lt;/p&gt;&lt;p&gt;
  And, most importantly, the harness is the thing that 
  &lt;strong&gt;
    decides not to send anything at all
  &lt;/strong&gt;
  . When it detects that the model is about to call the exact same tool with the exact same arguments it already called in the previous turn—the tell-tale heartbeat of an agent stuck in a loop, burning tokens in a circle—it refuses to execute, injects a notice telling the model to synthesise its answer from the results it already has, and moves on. The cheapest tokens are the ones you never spend. A harness that can say “no” is a harness that saves you money on every single session.
&lt;/p&gt;&lt;h2&gt;
  What the Model Is Allowed to Do: The Suspension and the Brakes
&lt;/h2&gt;&lt;p&gt;
  But a harness that merely feeds the model well is only half a car. The other half is the part that keeps the thing on the road when the engine, in its infinite statistical confidence, decides to floor it toward a brick wall. This is where quality actually comes from—not from the model’s intelligence, but from the scaffolding that refuses to let the model’s output become the product without passing through a gauntlet of human-shaped checkpoints.
&lt;/p&gt;&lt;p&gt;
  Quality, in my experience, is not something a model produces. It is something a harness 
  &lt;b&gt;
    enforces
  &lt;/b&gt;
   by constraining the model’s freedom at every fork in the road. The fewer choices the model has to make between two paths, the cleaner the result—a principle I have found to outweigh the quality of all prompts combined. Consider the mechanisms by which a real harness puts this principle into practice:
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    Persistent rules, not per-session rituals.
  &lt;/strong&gt;
   The harness carries a scoped rule engine. Rules with the 
&lt;code class=&quot;code-inline&quot;&gt;all:&lt;/code&gt;
   scope are injected into every prompt; rules with a narrower scope, like 
&lt;code class=&quot;code-inline&quot;&gt;cr:&lt;/code&gt;
   for code reviews, apply only when that command runs. This is the difference between engineering—where your conventions are encoded once, in the harness, and obeyed forever—and penance—where you manually correct the same mistake in every generated file until you retire.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The practices that teach the model your house style.
  &lt;/strong&gt;
   When the harness first meets a project in an unfamiliar language, it asks you to point at exemplary codebases, walks through them, and squeezes out a concise, human-editable list of good practices for that language. Those practices are then injected into the context whenever the model works with that language. Feed it good code, get good code. This is not mysticism; it is pattern matching. The harness is what remembers to feed the model the good code, every single time, without you having to remember.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The plan gate.
  &lt;/strong&gt;
   This is the single most important quality mechanism I have built, and it is almost embarrassingly simple. When a batch of tool calls looks non-trivial—two or more file-modifying actions—the harness pauses, drafts a structured plan, and refuses to execute until a human has looked at it and pressed “approve.” No more watching your agent cheerfully barrel through eight steps in one breath, commit untested code to 
&lt;code class=&quot;code-inline&quot;&gt;main&lt;/code&gt;
  , and open a pull request titled “feat: everything.” The gate is the brake pedal. Without it, the engine is a runaway.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The circuit breaker.
  &lt;/strong&gt;
   When a tool fails three times in a row without producing anything, the harness disables it and tells the model to fall back to standard tools. This is the difference between an agent that learns from its mistakes and an agent that bangs its head against the same wall for forty turns while billing you by the token.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The validation gauntlet.
  &lt;/strong&gt;
   The harness is wired to the native linters and static analysers—
&lt;code class=&quot;code-inline&quot;&gt;oeditus_credo&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;propwise&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;credo&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;dialyzer&lt;/code&gt;
  —and can run them against the whole project, a diff, or a branch review. Quality is not asserted; it is 
  &lt;b&gt;
    executed
  &lt;/b&gt;
  . The model does not get to declare its work done; the linters get to declare it done, and they are considerably less susceptible to flattery than the model is.
&lt;/p&gt;&lt;p&gt;
  ▸ 
  &lt;strong&gt;
    The workflow engine.
  &lt;/strong&gt;
   For the occasions when a task is big enough to deserve ceremony, the harness runs a full pipeline: branch off 
&lt;code class=&quot;code-inline&quot;&gt;main&lt;/code&gt;
  , summarise the task, decide whether it can be split into non-clashing sub-tasks, execute those in physically isolated git worktrees under parallel BEAM processes, require tests and documentation, gate on the linters, and only then commit. The whole thing is version-controllable, so your entire team shares the same agent rules, the same quality gates, and the same workflow. The harness is where your engineering standards live in a form that survives the departure of the senior engineer who used to carry them around in his head.
&lt;/p&gt;&lt;h2&gt;
  The Architecture Is the Point
&lt;/h2&gt;&lt;p&gt;
  I have saved the most important point for last, because it is the one people find hardest to believe. A harness built on the BEAM—on Erlang/OTP and the actor model—is not merely more reliable than a single-threaded script; it is 
  &lt;b&gt;
    architecturally incapable
  &lt;/b&gt;
   of the failure mode that plagues every fragile harness out there. The agent’s cognitive state runs in an isolated process, decoupled from the processes that execute tools. When a tool crashes—and tools will crash, because tools run shell commands and shell commands are chaos—the supervision tree contains the damage. The brain survives. The conversation survives. The context, which you have paid for in tokens and in blood, survives intact.
&lt;/p&gt;&lt;p&gt;
  This is not a reliability footnote. This is a quality and token-economy argument wearing a hard hat. Every crash that wipes your session is not merely an annoyance; it is the total loss of every token you spent building that context, followed by the certain expenditure of twice as many tokens rebuilding it from scratch with a model that has already forgotten everything. Fault tolerance is not a luxury feature. Fault tolerance is the cheapest context-compression strategy ever invented, because the cheapest context is the context you never have to rebuild.
&lt;/p&gt;&lt;h2&gt;
  The Engine Is Not the Product
&lt;/h2&gt;&lt;p&gt;
  So here is the thesis, stated without hedging, in the hope that it survives contact with the comment section: 
  &lt;strong&gt;
    you are arguing about the wrong component.
  &lt;/strong&gt;
   The model is a commodity. They are all autocomplete, they are all statistical amplifiers, and the difference between the best and the merely excellent is a rounding error compared to the difference between a good harness and a bad one. A brilliant model in a fragile shell is a smart idea waiting for a crash. A mediocre model in a disciplined harness will out-ship a brilliant model in a shopping trolley every single time, because the harness is where the 
  &lt;b&gt;
    wise
  &lt;/b&gt;
   part of “Wise ∧ LLM = Wise²” lives.
&lt;/p&gt;&lt;p&gt;
  The engine does not win the race. The car does. And the car—the suspension that keeps it on the road, the brakes that stop it before the wall, the fuel injection that meters exactly how much petrol per stroke, and the driver who refuses to let it floor it into a lamppost—is the harness.
&lt;/p&gt;&lt;p&gt;
  Stop shopping for engines. Start shopping for cars.
&lt;/p&gt;&lt;p&gt;
  Happy prompting—and may your harness be the one that knows how to say no.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Previously, on the subject of refusing to lose your mind over autocomplete:
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Anti-Antipatterns in Elixir Library Guidelines</title>
<link>https://blog.yokerhood.com/anti-antipatterns-in-elixir-libraries</link>
<guid isPermaLink="false">xRvdwyVoKXS7YOXQnGpFnYmyIYeuz1y5unIQkw==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +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>Deconstructing the dogmatic anti-patterns section of the official Elixir library guidelines and demonstrating when every single one of them is actually needed.</content:encoded>
</item>
<item>
<title>Exploring Programming Languages · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/programming-languages</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-programming-languages.png"></enclosure>
<guid isPermaLink="false">Fxb0DXQHwGTj4c6sb9ZmrRpQ0eMnZSB1WjDZTw==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>Nott Løvland · Exploring Languages</description>
<content:encoded>&lt;p&gt;
  &lt;/p&gt;&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;code-inline&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;code-inline&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;i&gt;
    Monkey Patching
  &lt;/i&gt;
   turned the code to a zoo,
  &lt;br/&gt;
  When a library changed what 
&lt;code class=&quot;code-inline&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;code-inline&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;code-inline&quot;&gt;[] == ![]&lt;/code&gt;
  evaluated to 
&lt;code class=&quot;code-inline&quot;&gt;true&lt;/code&gt;
  ,
  &lt;br/&gt;
  and 
&lt;code class=&quot;code-inline&quot;&gt;NaN&lt;/code&gt;
   is a number? I’m sorry, that’s rude!
  &lt;br/&gt;
&lt;code class=&quot;code-inline&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;code-inline&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;code-inline&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>Your Orchestrator Is a Finite Automaton in Denial</title>
<link>https://blog.yokerhood.com/status-driven-orchestrator-in-denial</link>
<guid isPermaLink="false">4yHDAlKm7MJsob0y5_HMVYomiXfgQK0LiRwVjQ==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +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>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</content:encoded>
</item>
<item>
<title>Make Friends With Your AI Assistant</title>
<link>https://blog.yokerhood.com/make-claude-robust</link>
<guid isPermaLink="false">edueKtEr2EGsok2Ue5GZzZD14FbJcCa0RPRtTA==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>Several rules from my experience which would make your cooperation with AI assistants a charm</description>
<content:encoded>Several rules from my experience which would make your cooperation with AI assistants a charm</content:encoded>
</item>
<item>
<title>Mocks Are Your Friends, Not Your Servants</title>
<link>https://blog.yokerhood.com/mocks-are-your-friends</link>
<guid isPermaLink="false">nvwxhLAzh3-yA3P6FtdpZpeoDByp0lbi92KTDg==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>On treating mocks as protagonists of the testing narrative rather than disposable stand-ins hired to nod along</description>
<content:encoded>On treating mocks as protagonists of the testing narrative rather than disposable stand-ins hired to nod along</content:encoded>
</item>
<item>
<title>Head of the Agents and Assistants Department</title>
<link>https://blog.yokerhood.com/developing-as-ai</link>
<guid isPermaLink="false">apS1WVFvzMhf238oe7TvzYardFzvgc7ggnv4bA==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>A first-hand account of building the Cure programming language with AI agents as co-developers—what worked, what crashed spectacularly, and the distilled rules that emerged from the wreckage.</description>
<content:encoded>A first-hand account of building the Cure programming language with AI agents as co-developers—what worked, what crashed spectacularly, and the distilled rules that emerged from the wreckage.</content:encoded>
</item>
<item>
<title>Cure, Four Releases Deep: From FSMs to Furniture</title>
<link>https://blog.yokerhood.com/cure-v016-to-v019</link>
<guid isPermaLink="false">Rq8NmMuXOqCIxXdgRqHNTnZzqajoYjFjpc8a1A==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>A walk through Cure v0.16.0 through v0.19.1. A Finitomata-inspired FSM rewrite, the dependent-types core finally behaving like it promised, a pattern engine that actually matches, and a stack of ergonomics that should have been there six versions ago.</description>
<content:encoded>A walk through Cure v0.16.0 through v0.19.1. A Finitomata-inspired FSM rewrite, the dependent-types core finally behaving like it promised, a pattern engine that actually matches, and a stack of ergonomics that should have been there six versions ago.</content:encoded>
</item>
<item>
<title>The Long Road to Cure</title>
<link>https://blog.yokerhood.com/long-way-to-cure</link>
<guid isPermaLink="false">K1LbxxqrQ96xKsrfDSX0GNnHGRpm2i7IVVTwlw==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>Just under a year ago I started building a BEAM language with dependent types and first-class FSMs—chose the wrong implementation language, wrote spaghetti, had an LLM tell me it was garbage, shipped something embarrassing, then spent months rethinking. Here is what I learned and where Cure stands today.</description>
<content:encoded>Just under a year ago I started building a BEAM language with dependent types and first-class FSMs—chose the wrong implementation language, wrote spaghetti, had an LLM tell me it was garbage, shipped something embarrassing, then spent months rethinking. Here is what I learned and where Cure stands today.</content:encoded>
</item>
<item>
<title>A Score for an Invisible Orchestra · YokerHood · Aleksei Matiushkin</title>
<link>https://blog.yokerhood.com/metaast-for-the-rescue</link>
<enclosure type="image/jpeg" length="0" url="https://blog.yokerhood.com/og/post-en-metaast-for-the-rescue.png"></enclosure>
<guid isPermaLink="false">Bp7Ar5h916CueB93yGIA8heZy6PoWIrnMeOHpQ==</guid>
<pubDate>Sat, 31 Oct 2026 12:35:03 +0000</pubDate>
<description>Imagine a five-storey building with no lift, erected in the late fifties somewhere on the outskirts of Avtozavodskaya—or better still, in Kupchino. Every floor speaks its own language. Not figuratively but in the most literal sense: the ground floor communicates in Cyrillic, the second in Latin script, the third in ideograms, the fourth in cuneiform, and the fifth, in the manner of Wittgenstein, maintains a principled silence on the grounds that whereof one cannot speak, thereof one must be...</description>
<content:encoded>&lt;p&gt;
  Imagine a five-storey building with no lift, erected in the late fifties somewhere on the outskirts of Avtozavodskaya—or better still, in Kupchino. Every floor speaks its own language. Not figuratively but in the most literal sense: the ground floor communicates in Cyrillic, the second in Latin script, the third in ideograms, the fourth in cuneiform, and the fifth, in the manner of Wittgenstein, maintains a principled silence on the grounds that whereof one cannot speak, thereof one must be silent. The postman, delivering the correspondence, is obliged to carry five copies of one and the same letter, translated into each of these tongues, and to knock on the door every time, hoping the addressee has not moved to another floor.
&lt;/p&gt;&lt;p&gt;
  That is precisely how the world of programming is arranged—if one looks at it from the wings rather than the stalls. Every language has its own internal representation of code. Python stores its AST the way a thrifty housewife stores dry goods: in tidy labelled containers—
&lt;code class=&quot;code-inline&quot;&gt;BinOp&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;FunctionDef&lt;/code&gt;
  , 
&lt;code class=&quot;code-inline&quot;&gt;Name&lt;/code&gt;
  . Elixir, with characteristic self-assurance, uses triples 
&lt;code class=&quot;code-inline&quot;&gt;{atom, metadata, children}&lt;/code&gt;
   and calls them 
  &lt;i&gt;
    quoted expressions
  &lt;/i&gt;
  , as though what we have before us is not a syntax tree but a collection of quotations from William Blake. Ruby stockpiles its S-expressions the way an antiquarian bookseller stockpiles yellowing volumes, and Erlang, faithful to tradition, converses in tuples and atoms intelligible only to Ericsson engineers and, by a curious coincidence, doctoral students at a handful of Swedish universities.
&lt;/p&gt;&lt;p&gt;
  The problem is obvious to anyone who has ever attempted to build a code-analysis tool. Suppose you have written a superb cyclomatic-complexity analyser for Python. It is magnificent: it finds nested conditionals, counts branching points, draws control-flow graphs. Then a colleague comes along and asks, “Could you do one for Ruby?” And it transpires that all your work—all those tree walkers, all that pattern matching over Python’s AST—must be rewritten from scratch. From zero. For a different tree, with different nodes, different semantics, and different booby traps. And then a third colleague will turn up and request the same for Haskell.
&lt;/p&gt;&lt;p&gt;
  Imagine a conductor forced to relearn musical notation every time a new instrument joins the orchestra. Violin—one system of writing. Cello—another. Oboe—a third, with reversed polarity, no less. The trumpet flatly refuses to acknowledge the existence of the staff and insists on a tablature of its own invention. Absurd, of course. In the real world every instrument reads the same score. Notes, rhythm, dynamics are universal. Only the technique of execution differs.
&lt;/p&gt;&lt;p&gt;
  MetaAST is that score.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Before we turn to the details (and they deserve attention in the way a well-constructed detective plot deserves it), permit me a brief digression into theory. Fear not: no formulae, only an analogy. Though one formula will appear after all—but it is so elegant that failing to cite it would be a crime against aesthetics.
&lt;/p&gt;&lt;p&gt;
  In the early two-thousands—when mobile telephones already existed but had not yet taken charge of our lives—the OMG consortium (Object Management Group, bearing no relation whatsoever to the divine or the exclamatory) released a standard called MOF: Meta-Object Facility. Its essence fits into four lines, yet it took the industry two decades to understand those four lines. MOF defines a four-level hierarchy of models:
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M⁰
  &lt;/strong&gt;
   is running code. There it goes, spitting out results, crashing with errors, consuming memory. This is reality.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M¹
  &lt;/strong&gt;
   is a model of reality. For programs, it is the AST: the abstract syntax tree. Python’s 
&lt;code class=&quot;code-inline&quot;&gt;BinOp(op=Add(), left=Name(&amp;#39;x&amp;#39;), right=Num(5))&lt;/code&gt;
   is M¹. Elixir’s 
&lt;code class=&quot;code-inline&quot;&gt;{:+, [context: Elixir], [{:x, [], Elixir}, 5]}&lt;/code&gt;
   is also M¹. Every language describes its own code in its own M¹, the way every painter paints an apple in their own way.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M²
  &lt;/strong&gt;
   is the model of models. The meta-model. It defines what a node of 
  &lt;i&gt;
    any
  &lt;/i&gt;
   AST 
  &lt;i&gt;
    can be
  &lt;/i&gt;
  . Not a concrete node of a concrete language, but the 
  &lt;i&gt;
    concept
  &lt;/i&gt;
   of a node. A binary operation is neither Python’s 
&lt;code class=&quot;code-inline&quot;&gt;BinOp&lt;/code&gt;
   nor Elixir’s 
&lt;code class=&quot;code-inline&quot;&gt;{:+, ...}&lt;/code&gt;
  . A binary operation is the 
  &lt;i&gt;
    idea
  &lt;/i&gt;
   that two operands are connected by an operator. UML lives at the M² level. And so does MetaAST.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M³
  &lt;/strong&gt;
   is the meta-meta-model. That which defines what meta-models themselves can be. The type system, the rules of composition. MOF lives here. In the context of MetaAST, this role is played by Elixir’s type system—
&lt;code class=&quot;code-inline&quot;&gt;@type&lt;/code&gt;
   and 
&lt;code class=&quot;code-inline&quot;&gt;@spec&lt;/code&gt;
  .
&lt;/p&gt;&lt;p&gt;
  The fundamental difference between MetaAST and LLVM IR, Java bytecode, or any other intermediate representation lies precisely here: all of those are models (M¹). They describe concrete code in a concrete format. MetaAST is a meta-model (M²). It describes what descriptions of code 
  &lt;i&gt;
    can be
  &lt;/i&gt;
  . The difference is roughly the same as between a dictionary and a language: a dictionary catalogues words, whereas a language defines the rules by which those words are possible in the first place.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Metastatic is an Elixir library that implements this idea in code. The name, as befits a respectable technical project, is charged with a double meaning: 
  &lt;strong&gt;
    Met(a)-AST-atic
  &lt;/strong&gt;
  , that is, “pertaining to the meta-level of AST.” The medical connotations are the house’s treat.
&lt;/p&gt;&lt;p&gt;
  The architecture is three-layered, and this is not caprice but a consequence of theory:
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M².1 — Core.
  &lt;/strong&gt;
   Concepts present in 
  &lt;i&gt;
    every
  &lt;/i&gt;
   programming language on the planet. Literals, variables, binary operations, conditionals, function calls, assignments. Nothing exotic here. 
&lt;code class=&quot;code-inline&quot;&gt;x + 5&lt;/code&gt;
   in Python, Elixir, Ruby, Erlang, and Haskell is one and the same thing. An identical MetaAST representation. Literally:
&lt;/p&gt;&lt;p&gt;
  Five languages. One tree. One analysis tool. A score legible to any instrument in the orchestra.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M².2 — Extended.
  &lt;/strong&gt;
   Constructs that exist in 
  &lt;i&gt;
    most
  &lt;/i&gt;
   languages but not all. Loops, lambdas, collection operations, pattern matching, exception handling. Haskell knows nothing of imperative loops—so be it: it has recursion at the M².1 level. Ruby knows nothing of guards—no matter: the adapter’s metadata will preserve the context.
&lt;/p&gt;&lt;p&gt;
  &lt;strong&gt;
    M².3 — Native.
  &lt;/strong&gt;
   The emergency exit for constructs that resist generalisation. Rust’s lifetimes, Haskell’s type classes, Elixir’s metaprogramming. They are wrapped in 
&lt;code class=&quot;code-inline&quot;&gt;{:language_specific, :rust, ...}&lt;/code&gt;
  —like fragile porcelain in bubble wrap—and travel through the system without losing their identity, yet without claiming universality either.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Every MetaAST node is a triple: type, metadata, children (or value). The format is deliberately borrowed from Elixir’s quoted expressions, because if you already know how to write macros in Elixir, you already know how to work with MetaAST. The difference is semantic: where Elixir uses 
&lt;code class=&quot;code-inline&quot;&gt;:+&lt;/code&gt;
   (the operator itself), MetaAST uses 
&lt;code class=&quot;code-inline&quot;&gt;:binary_op&lt;/code&gt;
   (the 
  &lt;i&gt;
    concept
  &lt;/i&gt;
   of a binary operation) and tucks the operator into metadata. Where Elixir inlines literals, MetaAST wraps them in 
&lt;code class=&quot;code-inline&quot;&gt;{:literal, [subtype: :integer], 42}&lt;/code&gt;
  , ensuring structural uniformity.
&lt;/p&gt;&lt;p&gt;
  Language adapters form the bridge between M¹ and M². The Python adapter takes Python’s AST (obtained via subprocess) and 
  &lt;i&gt;
    abstracts
  &lt;/i&gt;
   it up to MetaAST. The Elixir adapter takes quoted expressions and does the same. The Ruby adapter—likewise. The inverse operation—
  &lt;i&gt;
    reification
  &lt;/i&gt;
  —turns MetaAST back into the native AST of the target language. This pair of operations, abstraction and reification, constitutes what mathematicians call a Galois connection:
&lt;/p&gt;&lt;pre&gt;&lt;code&gt;Adapter_L = (alpha_L, rho_L)

alpha_L: AS_L -&amp;gt; MetaAST x Metadata    (abstraction: M¹ -&amp;gt; M²)
rho_L:   MetaAST x Metadata -&amp;gt; AS_L    (reification: M² -&amp;gt; M¹)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
  What does this mean in practice? Here is a scenario. You have written a function-purity analyser. It operates on MetaAST: traverses the tree, looks for side effects (I/O, state mutation, calls to random-number generators), and renders its verdict. This analyser was written once. Once. And it works with Python, Elixir, Ruby, Erlang, and Haskell. Because it analyses not the Python or Elixir AST but the 
  &lt;i&gt;
    meta-level
  &lt;/i&gt;
  . You write:
&lt;/p&gt;&lt;p&gt;
  And precisely the same code, unchanged, works if you replace Python with Ruby and 
&lt;code class=&quot;code-inline&quot;&gt;print(&amp;#39;hello&amp;#39;)&lt;/code&gt;
   with 
&lt;code class=&quot;code-inline&quot;&gt;puts &amp;#39;hello&amp;#39;&lt;/code&gt;
  . Because both calls are 
&lt;code class=&quot;code-inline&quot;&gt;{:function_call, [name: &amp;quot;print&amp;quot;], [{:literal, [subtype: :string], &amp;quot;hello&amp;quot;}]}&lt;/code&gt;
   at the M² level.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  Let us return to MOF and the four-level hierarchy. Why is any of this necessary? Why erect meta-models when one could simply write a converter from one AST to another?
&lt;/p&gt;&lt;p&gt;
  The answer is simple and brutal, like the truth of life. A converter works pairwise. For five languages you need twenty converters (5 × 4). For ten—ninety. For twenty—three hundred and eighty. A meta-model works through a hub: each language connects once, via its own adapter. Five languages—five adapters. Ten—ten. Twenty—twenty. Linear growth instead of quadratic. A mathematician who had spent half his life as a reporter would appreciate the irony.
&lt;/p&gt;&lt;p&gt;
  But it is not only a matter of combinatorics. A meta-model provides 
  &lt;i&gt;
    standardisation
  &lt;/i&gt;
  . Every tool written for MetaAST is guaranteed to work with any language for which an adapter exists. This is not “Python support” and “Ruby support” as separate features. It is one feature: MetaAST support. Everything else follows as a consequence.
&lt;/p&gt;&lt;p&gt;
  OMG understood this in 2002 when it released MOF. The entire UML industry is built on the same principle: the meta-model defines what models 
  &lt;i&gt;
    can be
  &lt;/i&gt;
  , and concrete diagrams are merely instances of that meta-model. MDA (Model-Driven Architecture) took the idea further: transformations between models are defined at the meta-level and applied automatically to any instances.
&lt;/p&gt;&lt;p&gt;
  Metastatic does the same, but not for class diagrams—for the syntax trees of programs. It is not an IR, not a compiler, not a transpiler. It is the foundation upon which all of the above can be built—once, and for every language at once.
&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
  There is an old story about a farmer, I’ve heard from a stranger on a train. A man spends his entire life breeding different eggplant varieties. In decades, when the farmer is already old and blind, his fields have produced an unprecedented harvest. He calls for each and every agronomist in the world, they come, and the most famous one says, “These aubergines are adorable!” The farmer dies of frustration. The man does not know what 
  &lt;i&gt;
    aubergines
  &lt;/i&gt;
   are but he’s certain the great eggplant nobody would have called ‘aubergine.’ He considers his life wasted.
&lt;/p&gt;&lt;p&gt;
  The ASTs of different languages are the eggplants. MetaAST is the knowledge that they all grow on the same field. The difference between them is terminological, not semantic. Python’s 
&lt;code class=&quot;code-inline&quot;&gt;BinOp(op=Add())&lt;/code&gt;
  , Elixir’s 
&lt;code class=&quot;code-inline&quot;&gt;{:+, [], [...]}&lt;/code&gt;
  , and Ruby’s 
&lt;code class=&quot;code-inline&quot;&gt;s(:send, ..., :+, ...)&lt;/code&gt;
   are different names for one and the same thing: 
&lt;code class=&quot;code-inline&quot;&gt;{:binary_op, [category: :arithmetic, operator: :+], [left, right]}&lt;/code&gt;
  .
&lt;/p&gt;&lt;p&gt;
  One could, of course, spend an entire life rewriting tools for every new language. One could relearn musical notation each time, bow before every new instrument in the orchestra, translate letters into five tongues for five floors of one and the same building. But why, when one can ascend a single level of abstraction—to the place where eggplants and aubergines are indistinguishable, and the score is one for all?
&lt;/p&gt;</content:encoded>
</item>
<item>
<title>From proof of concept to a Bluesky DataPlane proxy</title>
<link>https://bitcrowd.dev/bluesky-dataplane</link>
<guid isPermaLink="false">D-1umFHUXHQ38-OCDPCo-JXcQikuIAW6BKCpvQ==</guid>
<pubDate>Fri, 30 Oct 2026 05:26:27 +0000</pubDate>
<description>The work that separates our proof of concept from a complete Bluesky DataPlane, and a timeline proxy setup that makes it useful much sooner.</description>
<content:encoded>&lt;p&gt;A few weeks ago, we &lt;a href=&quot;https://bitcrowd.dev/open-sourcing-our-bluesky-dataplane&quot;&gt;open sourced our proof of concept&lt;/a&gt; of a DataPlane for Bluesky.
It indexes posts and follows and serves timelines.&lt;/p&gt;&lt;p&gt;Getting from there to a complete DataPlane is a lot of work.
In this post, we first outline what that work is, with Postgres as the first database.
Then we look at a compromise that makes the proof of concept useful long before that: running it next to Bluesky&amp;#39;s open source DataPlane and letting it serve only the timelines.&lt;/p&gt;&lt;h2&gt;A complete DataPlane in Elixir&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#a-complete-dataplane-in-elixir&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Our goal, as outlined in our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;first post&lt;/a&gt;, is an open source DataPlane that is low effort to operate and scales way better than the reference implementation.&lt;/p&gt;&lt;p&gt;In our &lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#a-tale-of-two-workloads&quot;&gt;companion post&lt;/a&gt;, we split the DataPlane&amp;#39;s work into two paths.&lt;/p&gt;&lt;p&gt;The hot path serves timelines from memory.
The cold path covers records, threads and profiles, and lives in a database.&lt;/p&gt;&lt;p&gt;For the cold path, we want a storage interface, an Elixir behaviour, so the database can be swapped.
Postgres will be the first adapter.
Everyone who runs an AppView today already has their data in it, and the reference schema works fine for the cold path as long as you don&amp;#39;t build timelines from it.
If we start from that schema, an existing AppView can switch to our DataPlane without migrating its data.&lt;/p&gt;&lt;p&gt;Our proof of concept covers a small part of the hot path.&lt;/p&gt;&lt;p&gt;Here is what&amp;#39;s missing:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;A gRPC endpoint for Bluesky&amp;#39;s &lt;a href=&quot;https://github.com/bluesky-social/atproto/blob/main/packages/bsky/proto/bsky.proto&quot;&gt;&lt;code&gt;bsky.proto&lt;/code&gt;&lt;/a&gt;, which currently declares 124 methods. Many of them are lookups by URI or DID. Some, like search and notifications, are more work.&lt;/li&gt;&lt;li&gt;An indexer for every record type. The reference implementation has 21 indexer plugins: posts, likes, reposts, follows, blocks, lists, starter packs, verifications and more. Deletes need some care, because the firehose only tells you which record is gone, not what was in it.&lt;/li&gt;&lt;li&gt;A write path that batches inserts into Postgres and stores the firehose cursor in the same transaction as the records. That makes replaying events after a crash safe. The in-memory structures only get updated after the commit.&lt;/li&gt;&lt;li&gt;A subscription to &lt;a href=&quot;https://github.com/bluesky-social/atproto/tree/main/packages/bsync&quot;&gt;bsync&lt;/a&gt;. Mutes and bookmarks don&amp;#39;t come through the firehose.&lt;/li&gt;&lt;li&gt;A backfill. The firehose only tells you what happens from now on, so a new instance has to fetch the existing repositories first.&lt;/li&gt;&lt;li&gt;The follower graph in Roaring Bitmaps, with a stable mapping from DIDs to the integers the bitmaps need. Stable, because we want to snapshot the graph to disk to make restarts faster.&lt;/li&gt;&lt;li&gt;A startup routine that rebuilds the hot path from Postgres: load the follower graph and the recent posts, run the fan-out, and only then take traffic.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Most of it isn&amp;#39;t hard, and &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#next-steps&quot;&gt;most of the endpoints are straightforward&lt;/a&gt;, as we noted back in March.
It&amp;#39;s just a lot of work, and nobody can run the DataPlane in production before all of it is done.&lt;/p&gt;&lt;h2&gt;The next step: a timeline proxy&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#the-next-step-a-timeline-proxy&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The timeline is where Bluesky&amp;#39;s open source DataPlane gives in first.
As we described in &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;our first post on the topic&lt;/a&gt;, the user experience often degrades with fewer than &lt;code&gt;100_000&lt;/code&gt; users.
Many of the other requests are lookups by URI or DID, which Postgres handles well for much longer.&lt;/p&gt;&lt;p&gt;So instead of waiting for the complete version, we can run our DataPlane next to the open source one and let it serve only the timelines.
That&amp;#39;s the part our proof of concept already covers.
In the rest of this post, we look at how such a setup would work and what it takes to keep the two DataPlanes consistent.&lt;/p&gt;&lt;h2&gt;Splitting the DataPlane API&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#splitting-the-dataplane-api&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The setup we have in mind builds on how the AppView talks to its DataPlane.
It uses gRPC, and every call is a &lt;code&gt;POST&lt;/code&gt; to a path that names the method, for example &lt;code&gt;/bsky.Service/GetTimeline&lt;/code&gt;.
That makes it easy to send different methods to different services.&lt;/p&gt;&lt;p&gt;The AppView does accept a list of DataPlanes in &lt;code&gt;BSKY_DATAPLANE_URLS&lt;/code&gt;.
But it picks one of them at random for every call, and only tries another one when a call fails with &lt;code&gt;Unavailable&lt;/code&gt; or &lt;code&gt;Aborted&lt;/code&gt;.
That&amp;#39;s meant for balancing load between identical DataPlanes and won&amp;#39;t help us here.
Instead, we put a proxy that understands gRPC in between.&lt;/p&gt;&lt;p&gt;With &lt;a href=&quot;https://www.envoyproxy.io/&quot;&gt;Envoy&lt;/a&gt;, most of the work is done by two routes:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;route_config:

virtual_hosts:

-name: dataplane

domains:[&amp;quot;*&amp;quot;]

routes:

-match:{path:&amp;quot;/bsky.Service/GetTimeline&amp;quot;}

route:{cluster: dataplane_elixir }

-match:{prefix:&amp;quot;/&amp;quot;}

route:{cluster: dataplane_node }&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;GetTimeline&lt;/code&gt; goes to our Elixir DataPlane, every other call goes to Bluesky&amp;#39;s.
The Node DataPlane keeps doing what it did before: it consumes the firehose, writes to Postgres and answers all the other methods.&lt;/p&gt;&lt;p&gt;The Node DataPlane only speaks HTTP/1.1, so the status of each gRPC call comes back in an HTTP/1.1 trailer.
Envoy drops those by default, so the Node cluster needs &lt;code&gt;enable_trailers&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Our DataPlane gets its data from Postgres rather than from the firehose.&lt;/p&gt;&lt;p&gt;Our proof of concept can&amp;#39;t take over that route just yet.
It answers JSON on &lt;code&gt;/bsky.Service/GetTimeline&lt;/code&gt;, which is all our &lt;a href=&quot;https://github.com/bitcrowd/ATProto_firehose_simulator&quot;&gt;simulator&lt;/a&gt; needs, while the AppView sends protobuf messages over gRPC.
So the gRPC endpoint from the list above is the first thing the timeline proxy needs, too.&lt;/p&gt;&lt;h2&gt;Where the IDs come from&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#where-the-ids-come-from&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The DataPlane only returns a skeleton of a timeline: a list of post URIs, a repost URI where an item is a repost, and a cursor.
The AppView then hydrates that skeleton into full views.
It calls &lt;code&gt;GetPostRecords&lt;/code&gt;, &lt;code&gt;GetRepostRecords&lt;/code&gt;, &lt;code&gt;GetActors&lt;/code&gt;, &lt;code&gt;GetInteractionCounts&lt;/code&gt; and friends, and all of those end up at the Node DataPlane and Postgres.&lt;/p&gt;&lt;p&gt;So whatever our DataPlane returns has to mean the same thing to Postgres.
Luckily, atproto already gives us IDs that mean the same everywhere.
A post is identified by its AT-URI, like &lt;code&gt;at://did:plc:…/app.bsky.feed.post/3k…&lt;/code&gt;, and a user by their DID.
Both come from the network, not from either database.&lt;/p&gt;&lt;p&gt;Our proof of concept doesn&amp;#39;t use them yet.
It numbers posts with a counter as they arrive.
That&amp;#39;s good enough for the simulator, but Postgres has never heard of post number 4711.
The timeline entries have to carry URIs instead.&lt;/p&gt;&lt;p&gt;The integer IDs that Roaring Bitmaps need for the follower graph can stay private.
We can keep a mapping from DID to integer in one ETS table and the reverse in another, and translate right before we build the response.
Nobody outside our DataPlane ever sees them, so they don&amp;#39;t have to match anything in Postgres.&lt;/p&gt;&lt;h3&gt;Agreeing on time&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#agreeing-on-time&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;With URIs in place, both DataPlanes talk about the same posts.
They can still disagree about when a post exists.
If both consume the firehose on their own, our DataPlane may put a post into a timeline before the Node DataPlane has written it to Postgres.
The AppView can&amp;#39;t hydrate that post and drops it from the page.
Reload a second later and it&amp;#39;s there.
It&amp;#39;s not a disaster, but users notice that kind of flakiness.&lt;/p&gt;&lt;p&gt;The way out is to not read the firehose at all, and read Postgres instead.
More precisely, our DataPlane follows the changes to two tables with &lt;a href=&quot;https://www.postgresql.org/docs/current/logical-replication.html&quot;&gt;logical replication&lt;/a&gt;:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;code&gt;follow&lt;/code&gt; holds the follower graph.&lt;/li&gt;&lt;li&gt;&lt;code&gt;feed_item&lt;/code&gt; holds a row for every post and every repost, with its author (&lt;code&gt;originatorDid&lt;/code&gt;), its CID and the timestamp timelines are sorted by (&lt;code&gt;sortAt&lt;/code&gt;).&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Those are exactly the two tables the reference implementation joins for every single timeline request.
The difference is that we don&amp;#39;t join them on read.
We fan out on write instead.&lt;/p&gt;&lt;p&gt;This gives us a useful guarantee: every URI we hand out is already committed in Postgres, so hydration will find it.
The opposite case is harmless.
A deleted post can linger in our memory for a moment, hydration comes back empty and the AppView drops it.&lt;/p&gt;&lt;p&gt;On the Postgres side, we need a publication for the two tables:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;-- requires wal_level = logical, and the Elixir side connects

-- with a role that has the REPLICATION attribute

CREATE PUBLICATION timeline FORTABLE bsky.follow, bsky.feed_item;



ALTERTABLE bsky.follow REPLICA IDENTITYFULL;

ALTERTABLE bsky.feed_item REPLICA IDENTITYFULL;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;By default, a delete only carries the primary key, which is the record&amp;#39;s URI for both tables.
With &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt;, an unfollow also tells us who unfollowed whom, and a deleted post tells us its author.
We pay for that with a bit more WAL, but we don&amp;#39;t need an extra lookup table just to process deletes.&lt;/p&gt;&lt;p&gt;On the Elixir side, &lt;a href=&quot;https://hexdocs.pm/postgrex/Postgrex.ReplicationConnection.html&quot;&gt;Postgrex&lt;/a&gt; comes with &lt;code&gt;Postgrex.ReplicationConnection&lt;/code&gt;.
A sketch of our replication process:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleDataplaneEx.Replicationdo

usePostgrex.ReplicationConnection



aliasDataplaneEx.{Bootstrap,Indexer,PgOutput}



@impltrue

defhandle_connect(state)do

    query =&amp;quot;CREATE_REPLICATION_SLOT timeline TEMPORARY LOGICAL pgoutput EXPORT_SNAPSHOT&amp;quot;

{:query, query, state}

end



@impltrue

defhandle_result([%Postgrex.Result{rows:[[_slot, _lsn, snapshot, _plugin]]}], state)do

# drop what we had, then load follows and recent feed items as of the snapshot

:ok=Bootstrap.load(snapshot)



    query =

&amp;quot;START_REPLICATION SLOT timeline LOGICAL 0/0 &amp;quot;&amp;lt;&amp;gt;

&amp;quot;(proto_version &amp;#39;1&amp;#39;, publication_names &amp;#39;timeline&amp;#39;)&amp;quot;



{:stream, query,[], state}

end



@impltrue

defhandle_data(&amp;lt;&amp;lt;?w, _wal_start::64, _wal_end::64, _clock::64, message::binary&amp;gt;&amp;gt;, state)do

    message

|&amp;gt;PgOutput.decode()

|&amp;gt;Indexer.apply_change()



{:noreply, state}

end



# plus start_link, init and the keepalive handling

# straight from the Postgrex docs

end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Postgrex hands us the raw &lt;a href=&quot;https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html&quot;&gt;pgoutput&lt;/a&gt; messages, decoding them is up to us.
It&amp;#39;s a small, well documented binary protocol, and binary pattern matching is one of the things Elixir is really good at.&lt;/p&gt;&lt;h3&gt;Persistence for free&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#persistence-for-free&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;This also takes care of a problem from our &lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#where-to-go-from-here&quot;&gt;last post&lt;/a&gt;: our data doesn&amp;#39;t survive a restart.
With Postgres as the source of truth, it doesn&amp;#39;t have to.&lt;/p&gt;&lt;p&gt;On every start, we create a temporary replication slot and let Postgres export a snapshot along with it.
A second connection opens a transaction on exactly that snapshot (&lt;code&gt;SET TRANSACTION SNAPSHOT &amp;#39;…&amp;#39;&lt;/code&gt;), loads the follows and the recent feed items, and runs the fan-out.
Then we start streaming from the slot.
Changes that happen during the load wait in the slot, so nothing gets lost and nothing arrives twice.
And since the slot is temporary, Postgres doesn&amp;#39;t hold on to WAL for a node that is down.&lt;/p&gt;&lt;h2&gt;Cursors and falling back&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#cursors-and-falling-back&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The AppView pages through a timeline with a cursor it gets from the DataPlane.
It doesn&amp;#39;t care which DataPlane created it.
The reference implementation builds the cursor from the last item on the page: its &lt;code&gt;sortAt&lt;/code&gt; in milliseconds and its CID, joined by two underscores, like &lt;code&gt;1792497600000__bafyrei…&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;If our DataPlane produces cursors in exactly that format, the Node DataPlane can continue any timeline we started.
That&amp;#39;s our way out whenever we can&amp;#39;t answer a request ourselves:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The user&amp;#39;s timeline isn&amp;#39;t there yet, for example because we are still loading after a restart.&lt;/li&gt;&lt;li&gt;The cursor points to posts older than what we keep in memory. &lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-hot-path-timelines-served-from-memory&quot;&gt;Timelines are bounded&lt;/a&gt;, so somebody who scrolls far enough will get there.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;In both cases, our DataPlane calls &lt;code&gt;GetTimeline&lt;/code&gt; on the Node DataPlane with the same cursor and passes the answer on.
For that to work, our timeline entries carry &lt;code&gt;sortAt&lt;/code&gt; and CID next to the URIs.
Sorting by them also gives us the same order as the reference implementation, ties included.&lt;/p&gt;&lt;p&gt;The reference implementation does two more things we have to copy.
It mixes the user&amp;#39;s own posts into their timeline, up to ten per page even when followed accounts fill it.
And it supports a &lt;code&gt;since&lt;/code&gt; parameter that bounds the range and comes back as the cursor once that range is used up.
If our DataPlane behaves differently, users would see their timeline change whenever we hand over to the Node DataPlane.&lt;/p&gt;&lt;h2&gt;What the timeline proxy doesn&amp;#39;t solve&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#what-the-timeline-proxy-doesnt-solve&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;This setup takes the most expensive query off Postgres.
It doesn&amp;#39;t change anything else:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The Node DataPlane still indexes the firehose and writes everything to Postgres. This is no improvement, but indexing is not a bottleneck in normal operations.&lt;/li&gt;&lt;li&gt;Threads, profiles, notifications, search and all the other requests are still served by the Node DataPlane from Postgres.&lt;/li&gt;&lt;li&gt;Postgres stays a single database that you can&amp;#39;t scale horizontally.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;So it lets you grow further with the open source DataPlane, but it won&amp;#39;t get you anywhere near the size of Bluesky. But that&amp;#39;s not our goal here: we are aiming to close the gap between a small community and over 100k users, until the user count ensures a serious engineering budget.&lt;/p&gt;&lt;p&gt;Our &lt;a href=&quot;https://github.com/bitcrowd/ATProto_firehose_simulator&quot;&gt;simulator&lt;/a&gt; works with both DataPlanes, so that&amp;#39;s the tool we&amp;#39;ll use to find out how far your setup can go.&lt;/p&gt;&lt;h2&gt;From the timeline proxy to the complete DataPlane&lt;a href=&quot;https://bitcrowd.dev/bluesky-dataplane#from-the-timeline-proxy-to-the-complete-dataplane&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The timeline proxy also gives us a way to get to the complete version step by step.&lt;/p&gt;&lt;p&gt;Every method we implement in Elixir becomes another route in Envoy.
We can move them over one at a time, starting with the ones that hurt most.
At first, they read the same Postgres tables the Node DataPlane writes to, which is why starting from the reference schema matters so much.&lt;/p&gt;&lt;p&gt;Before we switch a route, we want to know that our answers match.
Envoy can mirror requests to our DataPlane, but it throws the mirrored responses away.
So the comparison has to happen on our side: for a sample of requests, our DataPlane also asks the Node DataPlane and logs any difference.&lt;/p&gt;&lt;p&gt;The last piece to move is the indexer.
Once our DataPlane consumes the firehose itself and writes to Postgres, it doesn&amp;#39;t need the replication stream anymore.
At that point, the Node DataPlane can be switched off, and we have the complete DataPlane from the beginning of this post.&lt;/p&gt;&lt;p&gt;This is still a side project for us.
If you run an AppView and your Postgres is already sweating over timelines, we&amp;#39;d like to hear from you.
&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>Monitoring the Health of Oban Applications · Oban Pro</title>
<link>https://oban.pro/articles/monitoring-oban-health</link>
<enclosure type="image/jpeg" length="0" url="https://oban.pro/images/monitoring-oban-health/social.jpg"></enclosure>
<guid isPermaLink="false">8rKQXumKge63enamW5H8yZvH90r-Td--nMXVvw==</guid>
<pubDate>Fri, 23 Oct 2026 04:52:27 +0000</pubDate>
<description>Catch stalled queues, growing backlogs, and runaway jobs before your users do, with health checks, metrics, and execution timeouts.</description>
<content:encoded>&lt;p&gt;Oban tries its damnedest to keep queues chugging along in the face of unexpected issues. But stuff
happens. Queues back up, jobs run longer than we&amp;#39;d like, and sometimes bugs (no, never &lt;em&gt;our&lt;/em&gt; bugs)
jam up the whole system. You want to know these things are going down &lt;em&gt;before&lt;/em&gt; a customer tells
you, because for every customer that reaches out there&amp;#39;s a gaggle that were silently peeved.&lt;/p&gt;&lt;p&gt;It doesn&amp;#39;t have to be like that. Between telemetry events, periodic jobs, and some diligent
logging, Oban exposes the tools you need to alert on trouble early. No third-party collector
services necessary (though possibly helpful).&lt;/p&gt;&lt;h4&gt;🙋 Is This Only for Elixir?&lt;/h4&gt;&lt;p&gt;It&amp;#39;s not! The techniques here apply to &lt;a href=&quot;https://oban.pro/docs/py&quot;&gt;Oban for Python&lt;/a&gt; as well. The exception is querying
metrics, which needs &lt;code&gt;Met&lt;/code&gt; on an Elixir node.&lt;/p&gt;&lt;h2&gt;Taxonomy of Issues&lt;/h2&gt;&lt;p&gt;In our experience, there are three categories of issues that you should monitor to keep your Oban
system well-oiled. In order of &amp;quot;devastating&amp;quot; (everything seems broken) to &amp;quot;perplexing&amp;quot; (that
doesn&amp;#39;t look right), here they are:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Totally Stuck&lt;/strong&gt; - no queues are running and cron jobs aren&amp;#39;t getting scheduled&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backed Up&lt;/strong&gt; - queues can&amp;#39;t keep up with throughput and backlog keeps growing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Left Hanging&lt;/strong&gt; - queues are clogged with some jobs stuck executing forever&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;Each of those highly scientific categories has its own distinct flavor of breakage that you can
detect proactively and remediate.&lt;/p&gt;&lt;h2&gt;Totally Stuck&lt;/h2&gt;&lt;p&gt;A totally stuck system is the most catastrophic scenario, but thankfully the least ambiguous to
identify. Queues stall and simply stop processing any jobs. Within the confines of Oban, where
your database is up and rest of the application is running, this is &lt;em&gt;usually&lt;/em&gt; a config issue.&lt;/p&gt;&lt;h3&gt;Identification&lt;/h3&gt;&lt;p&gt;A simple technique for detecting stuck queues is to watch logs or metrics and alert when &lt;em&gt;no
events&lt;/em&gt; have occurred within a window of time. Make sure you&amp;#39;re &lt;a href=&quot;https://oban.hexdocs.pm/ready_for_production.html&quot;&gt;ready for production&lt;/a&gt; with
logs firing and you&amp;#39;re halfway there. There&amp;#39;s a catch though:&lt;/p&gt;&lt;p&gt;If you have periods of low activity where nothing is happening you&amp;#39;ll get a false positive and
be paged for no reason. To prevent that inactivity, set up a periodic job in a dedicated queue. The
job acts as a sentinel, and its purpose is to force some activity.&lt;/p&gt;&lt;p&gt;Start with a &lt;code&gt;health&lt;/code&gt; queue that won&amp;#39;t run other jobs (so it can&amp;#39;t back up), and schedule a cron
job that checks in every minute:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule MyApp.Sentinel do

  # Using the Pro worker for inline cron, you can also do it the old-fashioned way

  use Oban.Pro.Worker, queue: :health, cron: &amp;quot;* * * * *&amp;quot;



  @impl Oban.Pro.Worker

  def process(_job) do

    :my_app

    |&amp;gt; Application.fetch_env!(:some_check_in_url)

    |&amp;gt; Req.get!()



    :ok

  end

end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;With a monitoring service like Honeybadger, the &lt;a href=&quot;https://docs.honeybadger.io/guides/check-ins/&quot;&gt;check-in&lt;/a&gt; reports your Oban is &lt;a href=&quot;https://www.youtube.com/watch?v=ljIQo1OHkTI&quot;&gt;alive and
kicking&lt;/a&gt;. If the scheduled job doesn&amp;#39;t run and misses a check-in, you&amp;#39;ll get a pointed
alert.&lt;/p&gt;&lt;h3&gt;Remediation&lt;/h3&gt;&lt;p&gt;The alert tells you &lt;em&gt;when&lt;/em&gt; jobs aren&amp;#39;t processing, but it doesn&amp;#39;t tell you &lt;em&gt;why&lt;/em&gt; they aren&amp;#39;t. As
noted earlier, it&amp;#39;s &lt;em&gt;usually&lt;/em&gt; down to configuration. The most common issues are:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;Broken Leadership - a non-worker node has leadership or a zombie node is still hanging on&lt;/li&gt;
&lt;li&gt;Broken Notifications - using &lt;code&gt;PG&lt;/code&gt; based PubSub without a functional &lt;a href=&quot;https://www.erlang.org/doc/system/distributed.html&quot;&gt;Distributed Erlang&lt;/a&gt;
cluster&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;These &lt;em&gt;used&lt;/em&gt; to be issues, but that was &lt;a href=&quot;https://oban.hexdocs.pm/2.23.1/changelog.html#v2-23-1-2026-08-03&quot;&gt;prior to Oban v2.23.1&lt;/a&gt;, where we added fallbacks to
ensure processing continued despite any distribution issues. Read on if you&amp;#39;re on a previous Oban
version, or want to understand how to fix these issues for other reasons.&lt;/p&gt;&lt;h4&gt;Broken Leadership&lt;/h4&gt;&lt;p&gt;A leader node that can&amp;#39;t run plugins &lt;em&gt;won&amp;#39;t schedule cron jobs&lt;/em&gt;. Pruning and rescuing orphan jobs
stop too, so jobs keep piling up. This typically happens in a hybrid web/worker environment, where
web nodes don&amp;#39;t process jobs.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://oban.hexdocs.pm/troubleshooting.html#jobs-or-plugins-aren-t-running&quot;&gt;Prevent this situation&lt;/a&gt; by either disabling plugins &lt;em&gt;and&lt;/em&gt; leadership with &lt;code&gt;plugins: false&lt;/code&gt;, or
disabling leadership with &lt;code&gt;peer: false&lt;/code&gt;. Don&amp;#39;t use &lt;code&gt;plugins: [], queues: []&lt;/code&gt;, because Elixir deep
merges config, and an empty list combines with existing options rather than overwriting it.&lt;/p&gt;&lt;h4&gt;Broken Notifications&lt;/h4&gt;&lt;p&gt;Notifications must reach queues to alert them of &lt;code&gt;available&lt;/code&gt; jobs. When notifications can&amp;#39;t go
through, queues may stop fetching work entirely. Oban tries to detect unhealthy notifiers and log
warnings when connectivity is broken. However, in a distributed cluster &lt;em&gt;a node will still receive
messages from itself&lt;/em&gt;, which makes it impossible to accurately detect a disconnect.&lt;/p&gt;&lt;p&gt;Ensure you have a functional cluster when using the &lt;code&gt;Oban.Notifiers.PG&lt;/code&gt; notifier, or that you&amp;#39;re
&lt;a href=&quot;https://oban.hexdocs.pm/troubleshooting.html#no-notifications-with-pgbouncer&quot;&gt;not behind a connection pooler&lt;/a&gt; for &lt;code&gt;Oban.Notifiers.Postgres&lt;/code&gt;.&lt;/p&gt;&lt;h2&gt;Backed Up&lt;/h2&gt;&lt;p&gt;Queues are designed to hold jobs, but you run into trouble when jobs accumulate faster than your
system can process them. Maybe your app imports a batch of 100k records every morning, then spends
the next hour or so working through them. That&amp;#39;s completely normal. The trouble starts when jobs
&lt;a href=&quot;https://en.wikipedia.org/wiki/Little%27s_law&quot;&gt;keep arriving faster than you can finish them&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;As designed, the health sentinel from earlier won&amp;#39;t detect queue issues. To spot queue backups you
need to monitor the &lt;em&gt;backlog size&lt;/em&gt; and &lt;em&gt;overall throughput&lt;/em&gt;.&lt;/p&gt;&lt;h3&gt;Identification&lt;/h3&gt;&lt;p&gt;If you&amp;#39;re using Oban Web, you already have &lt;a href=&quot;https://oban-met.hexdocs.pm&quot;&gt;Oban.Met&lt;/a&gt; gathering the counts and metrics you
need. If you&amp;#39;re not using Oban Web...&lt;a href=&quot;https://oban.pro/web&quot;&gt;why not&lt;/a&gt;, &lt;a href=&quot;https://oban.pro/articles/oss-web-and-new-oban&quot;&gt;it&amp;#39;s free&lt;/a&gt;! Anyhow, &lt;code&gt;Met&lt;/code&gt; shares all
those values between nodes, and you can use the same data that powers the dashboard without
polling for your own metrics.&lt;/p&gt;&lt;p&gt;Python apps can &lt;a href=&quot;https://oban.pro/docs/py/web_dashboard.html#enable-metrics&quot;&gt;enable metrics&lt;/a&gt; to feed the dashboard, but there&amp;#39;s no &lt;code&gt;Met&lt;/code&gt; in Python to
query them from your own code. If an Elixir node runs alongside your Python workers, it can query
everything. Otherwise, the dashboard is your ticket to backlog and throughput, and a telemetry
handler on &lt;code&gt;oban.job.stop&lt;/code&gt; can feed your own collector.&lt;/p&gt;&lt;p&gt;Start by tracking how many jobs are waiting in each queue:&lt;/p&gt;&lt;p&gt;That will give you the freshest count, broken down by queue, so you can see where jobs are
accumulating. Then, to see how quickly you&amp;#39;re working through the accumulated jobs, check
the &lt;code&gt;exec_count&lt;/code&gt; in one-minute buckets for the past five minutes:&lt;/p&gt;&lt;p&gt;The counts tell you how much work is available, and how quickly it&amp;#39;s being completed. To make
them useful beyond an &lt;code&gt;iex&lt;/code&gt; session, you&amp;#39;ll want to export them to your metrics collector of
choice.&lt;/p&gt;&lt;p&gt;Here&amp;#39;s a cron function (&lt;a href=&quot;https://oban.pro/docs/pro/1.8.3/Oban.Pro.Decorator.html#module-cron-scheduling&quot;&gt;new in Pro v1.8&lt;/a&gt;) that runs every minute, in the health queue, and
sends the latest metrics to a StatsD-type collector. From there, you can set up per-queue alerts
when throughput is falling behind:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;@job cron: &amp;quot;* * * * *&amp;quot;, queue: :health

def report_queue_metrics do

  queues = Oban.Met.labels(&amp;quot;queue&amp;quot;)



  available =

    Oban.Met.latest(Oban, :full_count,

      filters: [state: &amp;quot;available&amp;quot;],

      group: &amp;quot;queue&amp;quot;,

      lookback: 5

    )



  completed =

    Oban

    |&amp;gt; Oban.Met.timeslice(:exec_count,

      filters: [state: &amp;quot;completed&amp;quot;],

      group: &amp;quot;queue&amp;quot;,

      lookback: 60,

      by: 60

    )

    |&amp;gt; Enum.reduce(%{}, fn {_bucket, count, queue}, acc -&amp;gt;

      Map.update(acc, queue, count, &amp;amp;(&amp;amp;1 + count))

    end)



  Enum.each(queues, fn queue -&amp;gt;

    :ok = Statsd.gauge(&amp;quot;#{queue}.available&amp;quot;, Map.get(available, queue, 0))

    :ok = Statsd.gauge(&amp;quot;#{queue}.completed&amp;quot;, Map.get(completed, queue, 0))

  end)

end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This assumes the enclosing module uses &lt;code&gt;Oban.Pro.Decorator&lt;/code&gt; and &lt;code&gt;Statsd&lt;/code&gt; is your configured
StatsD client.&lt;/p&gt;&lt;p&gt;Only you know the proper thresholds for your application, based on how much delay you can
tolerate. Nightly imports may have far more leeway than a queue delivering timely password resets.&lt;/p&gt;&lt;h3&gt;Remediation&lt;/h3&gt;&lt;p&gt;A growing backlog is a mismatch between incoming work and system capacity. Knowing there&amp;#39;s an
issue doesn&amp;#39;t tell you &lt;em&gt;why&lt;/em&gt; it happened, and you&amp;#39;ll need to do some investigation.&lt;/p&gt;&lt;p&gt;Check whether you&amp;#39;re receiving more jobs, whether jobs are taking longer, or whether you&amp;#39;ve
reduced the queue&amp;#39;s concurrency. It could be time to increase concurrency, increase available
CPUs, &lt;a href=&quot;https://oban.pro/docs/pro/1.8.3/Oban.Pro.Plugins.DynamicScaler.html&quot;&gt;(auto?)-scale to more nodes&lt;/a&gt;, process &lt;a href=&quot;https://oban.pro/docs/pro/1.8.3/Oban.Pro.Chunk.html&quot;&gt;jobs together in chunks&lt;/a&gt;, or &lt;a href=&quot;https://oban.pro/docs/pro/1.8.3/Oban.Pro.Engine.html#module-global-concurrency&quot;&gt;tune some
global concurrency&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Just be sure you get to the bottom of the slowdown before turning the dial.&lt;/p&gt;&lt;h2&gt;Left Hanging&lt;/h2&gt;&lt;p&gt;Without a timeout, an Oban job can run forever. Well, &amp;quot;forever&amp;quot; meaning until the application
restarts, but that&amp;#39;s long enough to cause trouble. An infinite await, a hanging network call, or
an unbounded query can leave a job squatting without accomplishing anything.&lt;/p&gt;&lt;p&gt;One hanging job reduces a queue&amp;#39;s capacity, and enough of them will stop processing altogether.
Meanwhile, your application is running, the health checks are fine, and everything looks alive.&lt;/p&gt;&lt;h3&gt;Identification&lt;/h3&gt;&lt;p&gt;Start by deciding how long each worker should &lt;em&gt;reasonably&lt;/em&gt; take. A job that transcodes video
naturally takes more time than one that sends an email. Again, only you know the right numbers for
your system.&lt;/p&gt;&lt;p&gt;Met already tracks execution time through &lt;code&gt;exec_time&lt;/code&gt;, and it includes a &lt;code&gt;worker&lt;/code&gt; label to help
identify which jobs are taking too long. As we did earlier, check the longest executions per
worker in one-minute buckets:&lt;/p&gt;&lt;p&gt;That gives you five minutes of execution times for jobs that succeeded, failed, or were cancelled.&lt;/p&gt;&lt;p&gt;Export those values alongside the queue metrics from the previous section, and alert when workers
exceed their expected duration (with some padding). Note that Met records in native time units by
default, so convert the values to &lt;a href=&quot;https://oban-met.hexdocs.pm/Oban.Met.html#module-sketch-time-unit&quot;&gt;something more useful&lt;/a&gt;, like milliseconds, before
exporting.&lt;/p&gt;&lt;p&gt;There&amp;#39;s an important catch here: exec time is only recorded after an attempt ends. Jobs that hang
forever will never report their duration, making those outliers effectively invisible. That&amp;#39;s
why alerting alone isn&amp;#39;t enough.&lt;/p&gt;&lt;h3&gt;Remediation&lt;/h3&gt;&lt;p&gt;A &lt;code&gt;timeout/1&lt;/code&gt; callback defined on the worker will put an upper bound on each execution. For
example, to set a static two-minute timeout:&lt;/p&gt;&lt;p&gt;Choose a timeout that leaves headroom for variation, and set your threshold below it. That will
help you spot workers that regularly take longer than expected, even when they finish before the
timeout.&lt;/p&gt;&lt;p&gt;When a job times out, execution is terminated with an &lt;code&gt;Oban.TimeoutError&lt;/code&gt; exception and
potentially retried if there are remaining attempts. As a bonus, the failure contributes to
&lt;code&gt;exec_time&lt;/code&gt; measurements, which become visible in the timing report.&lt;/p&gt;&lt;p&gt;If the work itself expires—like a notification that would arrive a day late—&lt;a href=&quot;https://oban.pro/docs/pro/1.8.3/Oban.Pro.Worker.html#module-job-deadlines&quot;&gt;job deadlines&lt;/a&gt;
in Pro will cancel it on its next run after the deadline passes. Setting &lt;code&gt;force: true&lt;/code&gt; can cancel the job
mid-execution too.&lt;/p&gt;&lt;p&gt;Use timeouts to limit execution, and deadlines to limit how long the job sticks around. Check a
job&amp;#39;s network calls, database queries, and potential deadlocks and apply inner limits wherever
possible. That way you&amp;#39;ll get more meaningful timeouts, and a chance for the job to handle the
failure before Oban takes care of it.&lt;/p&gt;&lt;h2&gt;Knowing is a Fraction of the Battle&lt;/h2&gt;&lt;p&gt;The issues we cover here have plagued Oban users for &lt;em&gt;years&lt;/em&gt; now. Wherever possible, we&amp;#39;ve
improved Oban (and Pro) itself to make entire categories of issues irrelevant.&lt;/p&gt;&lt;p&gt;Put these tips into practice in your own app, and catch issues before your customers do. Chances
are, you lovely humans are only going to glance over this...but your LLMs are going to love it.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;
        As usual, if you have any questions or comments, ask in the &lt;a href=&quot;https://elixirforum.com&quot;&gt;Elixir Forum&lt;/a&gt;. For future announcements and insight into
        what we&amp;#39;re working on next, subscribe to our
        newsletter.
      &lt;/p&gt;</content:encoded>
</item>
<item>
<title>Exploring OTP Patterns for Agentic Workflows</title>
<link>https://www.erlang-solutions.com/blog/exploring-otp-patterns-for-agentic-workflows/</link>
<guid isPermaLink="false">KMjdAOS2uY_2P-QYHPY1oBOaVaT5jfBPfvqdfA==</guid>
<pubDate>Thu, 22 Oct 2026 03:19:46 +0000</pubDate>
<description>Lorena Mireles explains how OTP manages state and recovery in agentic workflows. The post Exploring OTP Patterns for Agentic Workflows appeared first on Erlang Solutions.</description>
<content:encoded>&lt;h2&gt;&lt;strong&gt;Chapter 1: State&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;“AI is not my area.”&lt;/strong&gt; That was my answer when a friend suggested I give a talk about agentic workflows. I used that phrase a lot at the beginning of the year; I was pretty skeptical about the whole thing.&lt;/p&gt;&lt;p&gt;But the idea stuck. I started reading about what makes agentic systems hard to build, expecting to find something wild and exotic to dig into. What I found instead was a list of problems that sounded pretty familiar:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;State.&lt;/strong&gt;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Long-running processes.&lt;/strong&gt;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Communication.&lt;/strong&gt;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Concurrency.&lt;/strong&gt;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Coordination.&lt;/strong&gt;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Failure recovery.&lt;/strong&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;None of that was new to me. If you work with Elixir, Erlang, and the BEAM, it’s probably not new to you either.&lt;/p&gt;&lt;p&gt;A few months later, I was on stage at ElixirConf US giving the talk &lt;em&gt;“Exploring OTP Patterns for Agentic Workflows”&lt;/em&gt;. This series is based on that talk, but it goes deeper into the topics I couldn’t fully cover on stage.&lt;/p&gt;&lt;p&gt;The goal is to help you better understand agentic systems from the perspective of a BEAM developer. Not because OTP is an AI framework, nor because AI concepts map one-to-one to elements of the BEAM ecosystem, but because many of the engineering challenges these systems present are problems the BEAM community has been solving for decades.&lt;/p&gt;&lt;p&gt;So the question is simple:&lt;/p&gt;&lt;p&gt;What do I already know that can help me understand agentic workflows?&lt;/p&gt;&lt;p&gt;In this chapter, we start with the first item on the list: &lt;strong&gt;state&lt;/strong&gt;.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;A familiar problem, a different way of approaching it&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The example we’ll use throughout this chapter starts with a familiar situation: an endpoint that returns an error instead of the user’s data, and us leaning on an AI agent to debug and fix it.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;GET&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;users&lt;span&gt;/&lt;/span&gt;&lt;span&gt;:id&lt;/span&gt;&lt;span&gt;Hey&lt;/span&gt;&lt;span&gt;Claude&lt;/span&gt;:&lt;span&gt;The&lt;/span&gt;&lt;span&gt;GET&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;users&lt;span&gt;/&lt;/span&gt;&lt;span&gt;:id&lt;/span&gt;endpointisreturninga&lt;span&gt;500&lt;/span&gt;error
&lt;span&gt;for&lt;/span&gt;avalid&lt;span&gt;ID&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;It&lt;/span&gt;shouldreturntheuserobject&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Fix&lt;/span&gt;it&lt;span&gt;.&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Notice that the prompt describes what I want, not how to get there.&lt;/p&gt;&lt;p&gt;The agent takes this prompt as its &lt;strong&gt;goal&lt;/strong&gt;. It has a codebase to investigate and a set of tools to do so. Let’s say it starts by inspecting the router and then the controller. What it does next depends on what it finds along the way.&lt;/p&gt;&lt;p&gt;One distinction matters for everything that follows. &lt;strong&gt;The model at the center of the agent, a large language model (LLM), doesn’t open files or interact directly with the system.&lt;/strong&gt; It participates in deciding what should happen next, but it’s the tools that do the work, and their results become part of the context the model sees when it makes its next decision.&lt;/p&gt;&lt;p&gt;At a high level, we can represent it like this:&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;img src=&quot;https://www.erlang-solutions.com/wp-content/uploads/2026/10/image-1024x543.png&quot; alt=&quot;OTP agentic workflow represent&quot; title=&quot;&quot;/&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;For this series, that’s our working definition of an &lt;strong&gt;agentic workflow&lt;/strong&gt;: a workflow in which the model decides the path as it progresses, based on the context and on what it observes.&lt;/p&gt;&lt;p&gt;What interests us is everything behind that loop that makes it work as a system:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Where we keep track of what has happened.&lt;/li&gt;&lt;li&gt;Where the work runs.&lt;/li&gt;&lt;li&gt;How the results come back.&lt;/li&gt;&lt;li&gt;What work can happen concurrently.&lt;/li&gt;&lt;li&gt;What happens when things fail.&lt;/li&gt;&lt;li&gt;And finally, when the workflow stops.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;We already know how to reason about those problems, and that’s what this series explores.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;The system needs to remember what happened&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;After a few iterations, the agent has gathered information: the controller function behind the endpoint, the tests, the relevant spots in the codebase. If every iteration had to start from scratch, it would never make any progress.&lt;/p&gt;&lt;p&gt;But from the model’s perspective, each invocation starts with only the context it’s given. The model itself doesn’t carry anything over from one invocation to the next.&lt;/p&gt;&lt;p&gt;That means the system around it must preserve information about the execution: the goal, what has already happened, what work is still pending, how many steps it has taken, and so on.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;In other words, the workflow has state.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;Before we continue, let’s define another concept we’ve already mentioned and will need throughout this series: &lt;strong&gt;context&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;When the model needs to decide what to do next, it needs information about what has happened so far. Maybe the failing test output, the relevant function, the original goal, and the result of the last tool call. The model receives that information as context.&lt;/p&gt;&lt;p&gt;The two are related, but they’re not the same thing. One way to see it:&lt;/p&gt;&lt;p&gt;&lt;strong&gt;The system has state. The model receives context derived from that state.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The state usually contains more than the model needs for a particular decision. Step counts and pending work may matter to the system without needing to be exposed to the model. And the model can only take in a limited amount of context per invocation (what’s known as its context window), so even the relevant information may need to be selected, summarized, or trimmed.&lt;/p&gt;&lt;p&gt;What the model sees depends on the decision we’re asking it to make. This brings us to the first interesting question of the chapter.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Who owns the state?&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The answer is the essential building block of the BEAM world: &lt;strong&gt;a process&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;A process owns its state. No other process can reach into its memory and change it; anyone who wants to interact with it has to send it a message. For a workflow, that suggests one process per execution: it starts when a goal comes in, holds the state for that run, and exits when the run is over.&lt;/p&gt;&lt;p&gt;But the process doesn’t just hold a value. It has to react to messages that arrive on their own schedule: a tool result, a timeout, a request to cancel.&lt;/p&gt;&lt;p&gt;A GenServer gives us exactly that. But be careful: the GenServer isn’t the AI agent. It’s just the process we’ll use to own and coordinate one execution of the workflow.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A naming note:&lt;/em&gt;&lt;/strong&gt;&lt;em&gt;agent in this series always means an AI agent, never Elixir’s&lt;/em&gt;&lt;em&gt;Agent&lt;/em&gt;&lt;em&gt;module. That module wouldn’t be enough here anyway: it’s a simple abstraction around state, built to hold a value, not to react to messages as they arrive.&lt;/em&gt;&lt;/p&gt;&lt;p&gt;Before writing the process, let’s look at what it has to hold. Go back to our broken endpoint. The agent has already read the router, and the model’s next decision was to do two things at once: read the controller and run the tests. Neither has come back yet. At that moment, the state of the run looks like this:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;%&lt;span&gt;BlogOtp.Workflow&lt;/span&gt;{
&lt;span&gt;goal&lt;/span&gt;:&lt;span&gt;&amp;quot;The GET /users/:id endpoint is returning a 500 error...&amp;quot;&lt;/span&gt;,
&lt;span&gt;model&lt;/span&gt;:&lt;span&gt;BlogOtp.Model.Scripted&lt;/span&gt;,
&lt;span&gt;history&lt;/span&gt;:[
{{&lt;span&gt;:read_file&lt;/span&gt;,%{&lt;span&gt;path&lt;/span&gt;:&lt;span&gt;&amp;quot;lib/my_app_web/router.ex&amp;quot;&lt;/span&gt;}},
&lt;span&gt;~s(get &amp;quot;/users/:id&amp;quot;, UserController, :show)&lt;/span&gt;}
],
&lt;span&gt;pending&lt;/span&gt;:%{
&lt;span&gt;#Reference&amp;lt;...&amp;gt; =&amp;gt; {:read_file, %{path: &amp;quot;lib/my_app_web/controllers/user_controller.ex&amp;quot;}},&lt;/span&gt;&lt;span&gt;#Reference&amp;lt;...&amp;gt; =&amp;gt; {:run_tests, %{path: &amp;quot;test/my_app_web/controllers/user_controller_test.exs&amp;quot;}}&lt;/span&gt;},
&lt;span&gt;steps&lt;/span&gt;:&lt;span&gt;3&lt;/span&gt;,
&lt;span&gt;max_steps&lt;/span&gt;:&lt;span&gt;10&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each field maps to something we’ve already talked about:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;code&gt;goal&lt;/code&gt; is the prompt we gave the agent.&lt;/li&gt;&lt;li&gt;&lt;code&gt;model&lt;/code&gt; is the module that makes the decisions. In this chapter it’s a scripted stand-in for an LLM, so it doesn’t need anything extra to run.&lt;/li&gt;&lt;li&gt;&lt;code&gt;history&lt;/code&gt; is what has happened so far: each tool call paired with its result.&lt;/li&gt;&lt;li&gt;&lt;code&gt;pending&lt;/code&gt; is the work requested but not answered yet. Each request gets a unique reference, so when a result arrives, we know which one it answers.&lt;/li&gt;&lt;li&gt;&lt;code&gt;steps&lt;/code&gt; counts the tool calls requested so far, and &lt;code&gt;max_steps&lt;/code&gt; lets the system stop a run that goes on too long.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Only &lt;code&gt;goal&lt;/code&gt; and &lt;code&gt;history&lt;/code&gt; end up in the context. The rest exists for the system; the model never sees it.&lt;/p&gt;&lt;p&gt;Here’s the process that owns that state:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;defmodule&lt;/span&gt;&lt;span&gt;BlogOtp.Workflow&lt;/span&gt;&lt;span&gt;do&lt;/span&gt;&lt;span&gt;use&lt;/span&gt;&lt;span&gt;GenServer&lt;/span&gt;,&lt;span&gt;restart&lt;/span&gt;:&lt;span&gt;:transient&lt;/span&gt;&lt;span&gt;require&lt;/span&gt;&lt;span&gt;Logger&lt;/span&gt;&lt;span&gt;defstruct&lt;/span&gt;[&lt;span&gt;:goal&lt;/span&gt;,&lt;span&gt;:model&lt;/span&gt;,&lt;span&gt;history&lt;/span&gt;:[],&lt;span&gt;pending&lt;/span&gt;:%{},&lt;span&gt;steps&lt;/span&gt;:&lt;span&gt;0&lt;/span&gt;,&lt;span&gt;max_steps&lt;/span&gt;:&lt;span&gt;10&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;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;init(opts)&lt;span&gt;do&lt;/span&gt;state&lt;span&gt;=&lt;/span&gt;%&lt;span&gt;__MODULE__&lt;/span&gt;{
&lt;span&gt;goal&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;fetch!(opts,&lt;span&gt;:goal&lt;/span&gt;),
&lt;span&gt;model&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(opts,&lt;span&gt;:model&lt;/span&gt;,&lt;span&gt;BlogOtp.Model.Scripted&lt;/span&gt;),
&lt;span&gt;max_steps&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(opts,&lt;span&gt;:max_steps&lt;/span&gt;,&lt;span&gt;10&lt;/span&gt;)
}

{&lt;span&gt;:ok&lt;/span&gt;,state,{&lt;span&gt;:continue&lt;/span&gt;,&lt;span&gt;:next_step&lt;/span&gt;}}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_continue(&lt;span&gt;:next_step&lt;/span&gt;,state),&lt;span&gt;do&lt;/span&gt;:next_step(state)

&lt;span&gt;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_info({&lt;span&gt;:tool_result&lt;/span&gt;,ref,result},state)
&lt;span&gt;when&lt;/span&gt;is_map_key(state&lt;span&gt;.&lt;/span&gt;pending,ref)&lt;span&gt;do&lt;/span&gt;{call,pending}&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;pop(state&lt;span&gt;.&lt;/span&gt;pending,ref)

state&lt;span&gt;=&lt;/span&gt;%{
state
&lt;span&gt;|&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;:pending,
&lt;span&gt;history&lt;/span&gt;:[{call,result}&lt;span&gt;|&lt;/span&gt;state&lt;span&gt;.&lt;/span&gt;history]
}

next_step(state)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;# A result we aren&amp;#39;t waiting for: a duplicate, or a late arrival&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_info({&lt;span&gt;:tool_result&lt;/span&gt;,_ref,_result},state),&lt;span&gt;do&lt;/span&gt;:{&lt;span&gt;:noreply&lt;/span&gt;,state}

&lt;span&gt;# Still waiting on results: keep waiting&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;next_step(%{&lt;span&gt;pending&lt;/span&gt;:pending}&lt;span&gt;=&lt;/span&gt;state)&lt;span&gt;when&lt;/span&gt;map_size(pending)&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;,
&lt;span&gt;do&lt;/span&gt;:{&lt;span&gt;:noreply&lt;/span&gt;,state}

&lt;span&gt;# The system decides when to stop, not only the model&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;next_step(%{&lt;span&gt;steps&lt;/span&gt;:steps,&lt;span&gt;max_steps&lt;/span&gt;:max}&lt;span&gt;=&lt;/span&gt;state)&lt;span&gt;when&lt;/span&gt;steps&lt;span&gt;&amp;gt;=&lt;/span&gt;max,
&lt;span&gt;do&lt;/span&gt;:finish({&lt;span&gt;:stopped&lt;/span&gt;,&lt;span&gt;:max_steps&lt;/span&gt;},state)

&lt;span&gt;defp&lt;/span&gt;next_step(state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;# Chapter 1 shortcut: the owner calls the model itself&lt;/span&gt;&lt;span&gt;case&lt;/span&gt;state&lt;span&gt;.&lt;/span&gt;model&lt;span&gt;.&lt;/span&gt;decide(build_context(state))&lt;span&gt;do&lt;/span&gt;{&lt;span&gt;:tools&lt;/span&gt;,calls}&lt;span&gt;-&amp;gt;&lt;/span&gt;{&lt;span&gt;:noreply&lt;/span&gt;,request(calls,state)}
{&lt;span&gt;:done&lt;/span&gt;,answer}&lt;span&gt;-&amp;gt;&lt;/span&gt;finish({&lt;span&gt;:done&lt;/span&gt;,answer},state)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;span&gt;# Nobody executes these yet: each call waits in `pending` for its result&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;request(calls,state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;Enum&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;reduce(calls,state,&lt;span&gt;fn&lt;/span&gt;call,state&lt;span&gt;-&amp;gt;&lt;/span&gt;%{state&lt;span&gt;|&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;:&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;put(state&lt;span&gt;.&lt;/span&gt;pending,make_ref(),call),&lt;span&gt;steps&lt;/span&gt;:state&lt;span&gt;.&lt;/span&gt;steps&lt;span&gt;+&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;}
&lt;span&gt;end&lt;/span&gt;)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;finish(outcome,state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;Logger&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;info(&lt;span&gt;&amp;quot;Workflow finished: &lt;/span&gt;&lt;span&gt;#{&lt;/span&gt;inspect(outcome)&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&amp;quot;&lt;/span&gt;)
{&lt;span&gt;:stop&lt;/span&gt;,&lt;span&gt;:normal&lt;/span&gt;,state}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;# What the model gets to see, in its simplest form&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;build_context(state)&lt;span&gt;do&lt;/span&gt;[{&lt;span&gt;:goal&lt;/span&gt;,state&lt;span&gt;.&lt;/span&gt;goal}&lt;span&gt;|&lt;/span&gt;&lt;span&gt;Enum&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;reverse(state&lt;span&gt;.&lt;/span&gt;history)]
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The loop lives in &lt;code&gt;next_step/1&lt;/code&gt;, which works like this:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;If results are still pending, it waits.&lt;/li&gt;&lt;li&gt;If the run has gone on too long, it stops.&lt;/li&gt;&lt;li&gt;Otherwise, it builds the context and asks the model what to do next: either more tool calls, which go into &lt;code&gt;pending&lt;/code&gt;, or a final answer, which ends the process normally.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;And &lt;code&gt;restart: :transient&lt;/code&gt; tells a supervisor to restart the process only if it terminates abnormally: a run that finishes with&lt;code&gt;:normal&lt;/code&gt; stays finished.&lt;/p&gt;&lt;p&gt;Two things are deliberately missing in this chapter. First, nobody executes the tools yet: requests sit in &lt;code&gt;pending&lt;/code&gt; until someone sends back a &lt;code&gt;{:tool_result, ref, result}&lt;/code&gt; message. Second, the owner calls the model directly, even though a model call is slow. Both are problems we’ll come back to.&lt;/p&gt;&lt;p&gt;The code also shows the state/context distinction from the previous section. &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;steps&lt;/code&gt; and &lt;code&gt;max_steps&lt;/code&gt; exist for the system; the model never sees them. And the context is the output of &lt;code&gt;build_context/1&lt;/code&gt;, a plain function over the state, so we can test what the model will see without calling a model.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;One message at a time&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;The code also gives us serialized access to the state.&lt;/p&gt;&lt;p&gt;Look at &lt;code&gt;pending&lt;/code&gt;in the example above. Suppose the controller’s contents and the test output come back at almost the same time. They arrive as messages, and the process handles them one at a time. Each &lt;code&gt;handle_info/2&lt;/code&gt; runs to completion against a consistent state before the next message is processed. There are no locks; nothing else can change the state in the meantime, and the context for the next model call is never built from a half-applied update.&lt;/p&gt;&lt;p&gt;The first result is simply recorded: &lt;code&gt;pending&lt;/code&gt; still holds the other request, so &lt;code&gt;next_step/1&lt;/code&gt;keeps waiting. Only when the second result arrives does the model get to decide, with both observations in its context.&lt;/p&gt;&lt;p&gt;But that same property has a cost.&lt;/p&gt;&lt;p&gt;A GenServer handles one message at a time, and the work in this loop is slow: a model call can take seconds, while a test suite can take much longer. If the process did that work itself, it couldn’t handle anything in the meantime. Messages would pile up in its mailbox: a result, a timeout, a request to cancel, all waiting their turn.&lt;/p&gt;&lt;p&gt;And that’s exactly what our chapter 1 shortcut does with the model call. It works here because our scripted model answers instantly; a real one wouldn’t.&lt;/p&gt;&lt;p&gt;So owning the state and doing the work pull in opposite directions.&lt;/p&gt;&lt;p&gt;The owner process needs to stay available, so the work has to happen somewhere else. That’s why nobody executes the tools yet: the owner shouldn’t be the one doing it. We’ll come back to that.&lt;/p&gt;&lt;p&gt;First, there’s a more urgent question: everything above lives in memory.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;If this process dies, what happens to the run?&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;&lt;em&gt;“Let it crash”&lt;/em&gt; works because a supervisor can start a new process in a known good state. But for a GenServer, that known good state is whatever init/1 builds. For a workflow, that means starting from the beginning, which is exactly what we don’t want.&lt;/p&gt;&lt;p&gt;Think about our endpoint. By the time the process crashes, the model may have already made several decisions: it read the router, read the controller, ran the tests, maybe even edited the controller. Starting over means repeating all of that, paying for it again in time and in model calls. And some of that work, like editing a file, may not be something we want to do twice.&lt;/p&gt;&lt;p&gt;The process can own the workflow state while it’s running, but that doesn’t mean process memory has to be the only place where that state lives.&lt;/p&gt;&lt;p&gt;We need enough durable information to reconstruct the workflow.&lt;/p&gt;&lt;p&gt;One way to do that is to record its progress as an append-only log. The goal is the first entry. Each tool call the model decides on becomes another, as does each tool result. As the workflow progresses, we only ever add these entries to the end.&lt;/p&gt;&lt;p&gt;For our endpoint, the log might look like this when the process crashes:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;{&lt;span&gt;:goal&lt;/span&gt;,&lt;span&gt;&amp;quot;The GET /users/:id endpoint is returning a 500 error...&amp;quot;&lt;/span&gt;}
{&lt;span&gt;:decision&lt;/span&gt;,&lt;span&gt;1&lt;/span&gt;,{&lt;span&gt;:read_file&lt;/span&gt;,%{&lt;span&gt;path&lt;/span&gt;:&lt;span&gt;&amp;quot;lib/my_app_web/router.ex&amp;quot;&lt;/span&gt;}}}
{&lt;span&gt;:result&lt;/span&gt;,&lt;span&gt;1&lt;/span&gt;,&lt;span&gt;~s(get &amp;quot;/users/:id&amp;quot;, UserController, :show)&lt;/span&gt;}
{&lt;span&gt;:decision&lt;/span&gt;,&lt;span&gt;2&lt;/span&gt;,{&lt;span&gt;:read_file&lt;/span&gt;,%{&lt;span&gt;path&lt;/span&gt;:&lt;span&gt;&amp;quot;lib/my_app_web/controllers/user_controller.ex&amp;quot;&lt;/span&gt;}}}
{&lt;span&gt;:decision&lt;/span&gt;,&lt;span&gt;3&lt;/span&gt;,{&lt;span&gt;:run_tests&lt;/span&gt;,%{&lt;span&gt;path&lt;/span&gt;:&lt;span&gt;&amp;quot;test/my_app_web/controllers/user_controller_test.exs&amp;quot;&lt;/span&gt;}}}
{&lt;span&gt;:result&lt;/span&gt;,&lt;span&gt;2&lt;/span&gt;,&lt;span&gt;~s[def show(conn, %{&amp;quot;id&amp;quot; =&amp;gt; id}) do ... json(conn, user) ...]&lt;/span&gt;}
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Everything the process knew is there: the goal, every decision, every result that came back. Even the request that’s still waiting for its answer.&lt;/p&gt;&lt;p&gt;If that log becomes the source from which we reconstruct the workflow state, we’re using the same core idea as &lt;strong&gt;event sourcing&lt;/strong&gt;.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Rebuilding the state from the log&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Let’s put that idea into code. Three things change: the workflow gets an id that survives the process, each tool call gets a step id the workflow assigns, and every state change goes through a single function.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;defstruct&lt;/span&gt;[&lt;span&gt;:id&lt;/span&gt;,&lt;span&gt;:goal&lt;/span&gt;,&lt;span&gt;:model&lt;/span&gt;,&lt;span&gt;history&lt;/span&gt;:[],&lt;span&gt;pending&lt;/span&gt;:%{},&lt;span&gt;steps&lt;/span&gt;:&lt;span&gt;0&lt;/span&gt;,&lt;span&gt;max_steps&lt;/span&gt;:&lt;span&gt;10&lt;/span&gt;]

&lt;span&gt;# Only updates the state: no work, no model calls, no messages&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;apply_step({&lt;span&gt;:goal&lt;/span&gt;,goal},state),&lt;span&gt;do&lt;/span&gt;:%{state&lt;span&gt;|&lt;/span&gt;&lt;span&gt;goal&lt;/span&gt;:goal}

&lt;span&gt;defp&lt;/span&gt;apply_step({&lt;span&gt;:decision&lt;/span&gt;,step_id,call},state)&lt;span&gt;do&lt;/span&gt;%{state&lt;span&gt;|&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;:&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;put(state&lt;span&gt;.&lt;/span&gt;pending,step_id,call),&lt;span&gt;steps&lt;/span&gt;:state&lt;span&gt;.&lt;/span&gt;steps&lt;span&gt;+&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;apply_step({&lt;span&gt;:result&lt;/span&gt;,step_id,result},state)&lt;span&gt;do&lt;/span&gt;{call,pending}&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;pop(state&lt;span&gt;.&lt;/span&gt;pending,step_id)
%{state&lt;span&gt;|&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;:pending,&lt;span&gt;history&lt;/span&gt;:[{call,result}&lt;span&gt;|&lt;/span&gt;state&lt;span&gt;.&lt;/span&gt;history]}
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each clause handles one kind of entry from the log. &lt;code&gt;apply_step/2&lt;/code&gt; never starts work, calls a model, or sends a message. That restriction will matter in a moment.&lt;/p&gt;&lt;p&gt;The owner is still the only one that writes:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_info({&lt;span&gt;:tool_result&lt;/span&gt;,step_id,result},state)
&lt;span&gt;when&lt;/span&gt;is_map_key(state&lt;span&gt;.&lt;/span&gt;pending,step_id)&lt;span&gt;do&lt;/span&gt;state&lt;span&gt;=&lt;/span&gt;record({&lt;span&gt;:result&lt;/span&gt;,step_id,result},state)
next_step(state)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;# Each call becomes a decision in the log, with a step id the workflow assigns&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;request(calls,state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;Enum&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;reduce(calls,state,&lt;span&gt;fn&lt;/span&gt;call,state&lt;span&gt;-&amp;gt;&lt;/span&gt;record({&lt;span&gt;:decision&lt;/span&gt;,state&lt;span&gt;.&lt;/span&gt;steps&lt;span&gt;+&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;,call},state)
&lt;span&gt;end&lt;/span&gt;)
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;# Persist first, then apply&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;record(entry,state)&lt;span&gt;do&lt;/span&gt;&lt;span&gt;:ok&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;BlogOtp.Log&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;append(state&lt;span&gt;.&lt;/span&gt;id,entry)
apply_step(entry,state)
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Notice two things.&lt;/p&gt;&lt;p&gt;The first is &lt;code&gt;step_id&lt;/code&gt;, which replaces the reference from the previous version. A reference created with &lt;code&gt;make_ref/0&lt;/code&gt; only identifies something within the running node, and the process waiting for it may be gone after a crash. A step id is just a number the workflow assigns when it records the decision, and it means the same thing to any process that reads the log.&lt;/p&gt;&lt;p&gt;The second is the order inside &lt;code&gt;record/2&lt;/code&gt;. We write to the log first, then apply the entry to the in-memory state. If the process dies between those two lines, the log is ahead of memory, and memory is the part we know how to rebuild. The other way around, the process could act on a step that no restart would ever see. And if the append fails, the process crashes before acting on anything.&lt;/p&gt;&lt;p&gt;The same rule applies to decisions: they’re recorded before anyone starts the work. In this chapter nobody executes the tools yet, but when someone does, the decision will already be in the log.&lt;/p&gt;&lt;p&gt;This puts a durable write on the owner’s critical path. That’s intentional: we don’t want the workflow to act on a transition it couldn’t recover after a crash.&lt;/p&gt;&lt;p&gt;&lt;em&gt;In this chapter,&lt;/em&gt;&lt;em&gt;&lt;code&gt;BlogOtp.Log&lt;/code&gt;&lt;/em&gt;&lt;em&gt;keeps its entries in an ETS table owned by its own process. It survives a workflow crash, but not a node restart. A real implementation would write to disk or a database; the contract, append first and read in order, stays the same.&lt;/em&gt;&lt;/p&gt;&lt;p&gt;Starting a run now means creating its log first:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;def&lt;/span&gt;start(goal,opts\\[])&lt;span&gt;do&lt;/span&gt;id&lt;span&gt;=&lt;/span&gt;new_id()
&lt;span&gt;:ok&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;BlogOtp.Log&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;append(id,{&lt;span&gt;:goal&lt;/span&gt;,goal})

&lt;span&gt;DynamicSupervisor&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;start_child(
&lt;span&gt;BlogOtp.WorkflowSupervisor&lt;/span&gt;,
{&lt;span&gt;__MODULE__&lt;/span&gt;,&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;put(opts,&lt;span&gt;:id&lt;/span&gt;,id)}
)
&lt;span&gt;end&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;:via(&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;fetch!(opts,&lt;span&gt;:id&lt;/span&gt;)))
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;defp&lt;/span&gt;via(id),&lt;span&gt;do&lt;/span&gt;:{&lt;span&gt;:via&lt;/span&gt;,&lt;span&gt;Registry&lt;/span&gt;,{&lt;span&gt;BlogOtp.WorkflowRegistry&lt;/span&gt;,id}}
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goal is written before the owner exists; from then on, the owner is the only writer. Registering the process under the workflow id in a&lt;code&gt;Registry&lt;/code&gt; with unique keys turns “only one writer” from an intention into a guarantee: a key points to at most one process, and a second attempt to start an owner for the same run would get&lt;code&gt;{:error, {:already_started, pid}}&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Look at what the process is started with: the id, not the goal. When a supervisor restarts a child, it calls the same start function, with the same arguments, from the child’s specification. The id is all a new process needs to find out everything else.&lt;/p&gt;&lt;div&gt;&lt;pre&gt;&lt;span&gt;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;init(opts)&lt;span&gt;do&lt;/span&gt;state&lt;span&gt;=&lt;/span&gt;%&lt;span&gt;__MODULE__&lt;/span&gt;{
&lt;span&gt;id&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;fetch!(opts,&lt;span&gt;:id&lt;/span&gt;),
&lt;span&gt;model&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(opts,&lt;span&gt;:model&lt;/span&gt;,&lt;span&gt;BlogOtp.Model.Scripted&lt;/span&gt;),
&lt;span&gt;max_steps&lt;/span&gt;:&lt;span&gt;Keyword&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;get(opts,&lt;span&gt;:max_steps&lt;/span&gt;,&lt;span&gt;10&lt;/span&gt;)
}

{&lt;span&gt;:ok&lt;/span&gt;,state,{&lt;span&gt;:continue&lt;/span&gt;,&lt;span&gt;:rebuild&lt;/span&gt;}}
&lt;span&gt;end&lt;/span&gt;&lt;span&gt;@impl&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;def&lt;/span&gt;handle_continue(&lt;span&gt;:rebuild&lt;/span&gt;,state)&lt;span&gt;do&lt;/span&gt;state&lt;span&gt;=&lt;/span&gt;state&lt;span&gt;.&lt;/span&gt;id
&lt;span&gt;|&amp;gt;&lt;/span&gt;&lt;span&gt;BlogOtp.Log&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;read()
&lt;span&gt;|&amp;gt;&lt;/span&gt;&lt;span&gt;Enum&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;reduce(state,&lt;span&gt;&amp;amp;&lt;/span&gt;apply_step&lt;span&gt;/&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;)

next_step(state)
&lt;span&gt;end&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;When &lt;code&gt;init/1&lt;/code&gt;returns &lt;code&gt;{:continue, :rebuild}&lt;/code&gt;, OTP runs &lt;code&gt;handle_continue/2&lt;/code&gt; before the process receives any other message, so no result can be handled while the state is still being rebuilt.&lt;/p&gt;&lt;p&gt;The same &lt;code&gt;apply_step/2&lt;/code&gt; that updates the workflow while it runs also rebuilds it after a crash. Now we can see why it had to be free of side effects: replaying the log must not run the tests again. And a new run isn’t a special case either: it’s a log with a single entry, the goal. The fold produces a state with nothing pending, and &lt;code&gt;next_step/1&lt;/code&gt;asks the model for its first decision.&lt;/p&gt;&lt;p&gt;The state is a fold over the log.&lt;/p&gt;&lt;p&gt;This changes what a restart means. The new process doesn’t need to remember what the old one knew. It only needs to rebuild enough of the workflow to continue from a known point. But careful!&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Rebuilding isn’t resuming&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Fold the log from our crash, and &lt;code&gt;pending&lt;/code&gt; holds exactly one entry: step 3.&lt;/p&gt;&lt;p&gt;&lt;code&gt;Run the tests&lt;/code&gt;&lt;/p&gt;&lt;p&gt;There’s no result for it. So &lt;code&gt;next_step/1&lt;/code&gt; sees something pending and waits, for a result that may never come.&lt;/p&gt;&lt;p&gt;We know the decision was recorded. But did the work actually run? Maybe the request was never sent. Maybe the tests were still running when the process crashed. Maybe they finished, and the result was lost before it could be recorded.&lt;/p&gt;&lt;p&gt;For a test suite, running it again is harmless. But imagine our agent’s last step had been:&lt;/p&gt;&lt;p&gt;&lt;code&gt;Open a pull request&lt;/code&gt;&lt;/p&gt;&lt;p&gt;Now repeating it means a duplicate pull request. And some actions are even more delicate than that, like inserting a row into a database that must never exist twice, or charging a customer. Those are the ones where we have to be really careful.&lt;/p&gt;&lt;p&gt;Restarting the process gives us back the ability to continue the workflow. It doesn’t tell us whether to run an external action again.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Restart and retry are two different problems.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;That leaves two questions open: where the work actually happens while the owner stays available, and what to do with a step whose outcome we don’t know. The first question is where the next chapter picks up. The second one will have to wait until we talk about failure recovery.&lt;/p&gt;&lt;p&gt;The complete code repository for this series will be shared in Chapter 2.&lt;/p&gt;&lt;p&gt;Want to learn more about agentic workflows and OTP? &lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt;Get in touch&lt;/a&gt; with our ESL team.&lt;/p&gt;&lt;p&gt;The post &lt;a href=&quot;https://www.erlang-solutions.com/blog/exploring-otp-patterns-for-agentic-workflows/&quot;&gt;Exploring OTP Patterns for Agentic Workflows&lt;/a&gt; appeared first on &lt;a href=&quot;https://www.erlang-solutions.com&quot;&gt;Erlang Solutions&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Weekend Writing Prompt — Glowing Elixer – Facts, Fictions &amp; Fantasies</title>
<link>https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/</link>
<enclosure type="image/jpeg" length="0" url="https://mymindmappings.com/wp-content/uploads/2026/09/img_3897.jpg"></enclosure>
<guid isPermaLink="false">nOTsK6_Wvt-c0aT7d8koX3K2nUKL-htgOCR_ng==</guid>
<pubDate>Mon, 28 Sep 2026 13:41:32 +0000</pubDate>
<description>This post was written for Sammi Cox’s Weekend Writing Prompt, where the challenge is to write a poem or a piece of prose in exactly 34 words using the word “hokum.” The dusty traveling salesma…</description>
<content:encoded>&lt;body&gt;
	Skip to content

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

			&lt;div&gt;
				
				&lt;div&gt;
					&lt;div&gt;
						
				
					&lt;span&gt;Search for:&lt;/span&gt;
					
				
				
								&lt;/div&gt;
				&lt;/div&gt;
			&lt;/div&gt;
    	&lt;/div&gt;
	&lt;/div&gt;

	&lt;header&gt;
			&lt;div&gt;
				&lt;a href=&quot;https://mymindmappings.com/&quot;&gt;&lt;img src=&quot;https://mymindmappings.com/wp-content/uploads/2024/08/cropped-cropped-cropped-af217-eyes-1.jpg?w=672&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;/a&gt;				&lt;h1&gt;&lt;a href=&quot;https://mymindmappings.com/&quot;&gt;Facts, Fictions &amp;amp; Fantasies&lt;/a&gt;&lt;/h1&gt;
				&lt;h2&gt;My Mind Mappings&lt;/h2&gt;
			&lt;/div&gt;
	&lt;/header&gt;

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

		
			
&lt;article&gt;
	&lt;header&gt;

		&lt;h2&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/&quot;&gt;Weekend Writing Prompt — Glowing Elixer&lt;/a&gt;&lt;/h2&gt;
					&lt;div&gt;
				&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/&quot;&gt;&lt;time&gt;September 26, 2026&lt;/time&gt;&lt;time&gt;September 26, 2026&lt;/time&gt;&lt;/a&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/author/fivedotoh/&quot;&gt;Fandango&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;			&lt;/div&gt;
		
	&lt;/header&gt;

	
		&lt;div&gt;
			
&lt;p&gt;This post was written for Sammi Cox’s &lt;a href=&quot;https://sammiscribbles.wordpress.com/2026/09/26/weekend-writing-prompt-487-hokum/&quot;&gt;Weekend Writing Prompt&lt;/a&gt;, where the challenge is to write a poem or a piece of prose in exactly 34 words using the word “hokum.”&lt;/p&gt;



&lt;figure&gt;&lt;img src=&quot;https://mymindmappings.com/wp-content/uploads/2026/09/img_3897.jpg?w=640&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;/figure&gt;







&lt;p&gt;The dusty traveling salesman swore that his glowing elixir cured every known ailment under the sun.&lt;/p&gt;



&lt;p&gt;“Pure hokum,” muttered old doctor McKenzie, right before watching the town cripple start happily dancing down Main Street.&lt;/p&gt;



&lt;hr/&gt;



&lt;p&gt;Image conjured using Leonardo.ai.&lt;/p&gt;
&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;h3&gt;Share this:&lt;/h3&gt;&lt;div&gt;&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?share=twitter&quot;&gt;
				&lt;span&gt;Share on X (Opens in new window)&lt;/span&gt;
				&lt;span&gt;X&lt;/span&gt;
			&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?share=facebook&quot;&gt;
				&lt;span&gt;Share on Facebook (Opens in new window)&lt;/span&gt;
				&lt;span&gt;Facebook&lt;/span&gt;
			&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;Like&lt;/span&gt;&lt;/span&gt; &lt;span&gt;Loading...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;
	&lt;h3&gt;&lt;em&gt;Related&lt;/em&gt;&lt;/h3&gt;
&lt;/div&gt;&lt;/div&gt;					&lt;/div&gt;

	
	&lt;footer&gt;
		&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/category/flash-fiction/&quot;&gt;Flash Fiction&lt;/a&gt;&lt;/span&gt;&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/tag/elixir/&quot;&gt;elixir&lt;/a&gt;, &lt;a href=&quot;https://mymindmappings.com/tag/hokum/&quot;&gt;hokum&lt;/a&gt;, &lt;a href=&quot;https://mymindmappings.com/tag/main-street/&quot;&gt;Main Street&lt;/a&gt;, &lt;a href=&quot;https://mymindmappings.com/tag/town-cripple/&quot;&gt;town cripple&lt;/a&gt;, &lt;a href=&quot;https://mymindmappings.com/tag/traveling-salesman/&quot;&gt;traveling salesman&lt;/a&gt;, &lt;a href=&quot;https://mymindmappings.com/tag/weekend-writing-prompt/&quot;&gt;Weekend Writing Prompt&lt;/a&gt;&lt;/span&gt;	&lt;/footer&gt;
&lt;/article&gt;
				
	
			
&lt;div&gt;

			&lt;h3&gt;
			2 thoughts on “&lt;span&gt;Weekend Writing Prompt — Glowing Elixer&lt;/span&gt;”		&lt;/h3&gt;

		&lt;ol&gt;
			
	&lt;li&gt;
		&lt;article&gt;
			&lt;div&gt;
				&lt;img src=&quot;https://2.gravatar.com/avatar/81a195a4cf0c4b12c4678594114ce1295f9e3a342f723116ca7575e523d7b8c8?s=40&amp;amp;d=initials&amp;amp;r=G&quot; alt=&quot;Nancy&amp;#39;s Notes 🖊️♬&amp;#39;s avatar&quot; title=&quot;&quot;/&gt;				&lt;strong&gt;&lt;cite&gt;&lt;a href=&quot;http://theelephantstrunknr.wordpress.com&quot;&gt;Nancy&amp;#39;s Notes 🖊️♬&lt;/a&gt;&lt;/cite&gt;&lt;/strong&gt;				&lt;span&gt;September 26, 2026 / 1:04 pm&lt;/span&gt;
			&lt;/div&gt;

			
			&lt;div&gt;
				&lt;p&gt;Old Doc McKenzie’s seen it all! Good one, Fan.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?like_comment=226431&amp;amp;_wpnonce=b873ea0afe&quot;&gt;&lt;span&gt;Like&lt;/span&gt;&lt;/a&gt;&lt;span&gt;Liked by 1 person&lt;/span&gt;&lt;/p&gt;
			&lt;/div&gt;

			&lt;div&gt;
								&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?replytocom=226431#respond&quot;&gt;Reply&lt;/a&gt;&lt;/span&gt;			&lt;/div&gt;
		&lt;/article&gt;

	&lt;ul&gt;

	&lt;li&gt;
		&lt;article&gt;
			&lt;div&gt;
				&lt;img src=&quot;https://0.gravatar.com/avatar/31d4c3b0d3038543e5420b71bfc1a64f891a4774036acaccc2de5556b12a2f83?s=40&amp;amp;d=initials&amp;amp;r=G&quot; alt=&quot;Fandango&amp;#39;s avatar&quot; title=&quot;&quot;/&gt;				&lt;strong&gt;&lt;cite&gt;&lt;a href=&quot;https://mymindmappings.com&quot;&gt;Fandango&lt;/a&gt;&lt;/cite&gt;&lt;/strong&gt;				&lt;span&gt;September 26, 2026 / 2:33 pm&lt;/span&gt;
			&lt;/div&gt;

			
			&lt;div&gt;
				&lt;p&gt;That he has, old Doc McKenzie!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?like_comment=226436&amp;amp;_wpnonce=54088743a5&quot;&gt;&lt;span&gt;Like&lt;/span&gt;&lt;/a&gt;&lt;span&gt;Liked by 1 person&lt;/span&gt;&lt;/p&gt;
			&lt;/div&gt;

			&lt;div&gt;
								&lt;span&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/?replytocom=226436#respond&quot;&gt;Reply&lt;/a&gt;&lt;/span&gt;			&lt;/div&gt;
		&lt;/article&gt;

	&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
		&lt;/ol&gt;

		
	
	
		&lt;div&gt;
		&lt;h3&gt;Thoughts to share? &lt;small&gt;&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/#respond&quot;&gt;Cancel reply&lt;/a&gt;&lt;/small&gt;&lt;/h3&gt;


&lt;div&gt;


			
			&lt;/div&gt;&lt;p&gt;Δ
&lt;/p&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;div&gt;
		&lt;div&gt;
		&lt;div&gt;
&lt;figure&gt;&lt;a href=&quot;https://statcounter.com/&quot;&gt;&lt;img src=&quot;https://c.statcounter.com/13031919/0/a3bc9779/1/&quot; alt=&quot;Web Analytics&quot; title=&quot;&quot;/&gt;&lt;/a&gt;&lt;/figure&gt;
&lt;/div&gt;&lt;div&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&amp;quot;guide-statcounter_image&amp;quot; style=&amp;quot;display:inline;&amp;quot;&amp;gt;&amp;lt;img
src=&amp;quot;https://c.statcounter.com/13031919/0/a3bc9779/1/&amp;quot;
alt=&amp;quot;Web Analytics&amp;quot; 
referrerPolicy=&amp;quot;no-referrer-when-downgrade&amp;quot; /&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;	&lt;/div&gt;
	
	
	
	&lt;/div&gt;
			&lt;div&gt;
				&lt;a href=&quot;https://wordpress.com/?ref=footer_custom_powered&quot;&gt;Website Powered by WordPress.com&lt;/a&gt;.
				
							&lt;/div&gt;

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







	

		&lt;div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;div&gt;
	&lt;/div&gt;
	&lt;/div&gt;
			&lt;div&gt;
			
			&lt;ul&gt;
										&lt;li&gt;
							&lt;a href=&quot;https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/#comments&quot;&gt;
																&lt;span&gt;Comment							&lt;/span&gt;
							&lt;/a&gt;
						&lt;/li&gt;
											&lt;li&gt;
							
								&lt;span&gt;Reblog&lt;/span&gt;
							
						&lt;/li&gt;
											&lt;li&gt;
										
								&lt;span&gt;Subscribe&lt;/span&gt;
			
			
								&lt;span&gt;Subscribed&lt;/span&gt;
			
									&lt;div&gt;
								&lt;div&gt;
								&lt;div&gt;&lt;a href=&quot;https://mymindmappings.com&quot;&gt;&lt;img src=&quot;https://mymindmappings.com/wp-content/uploads/2024/08/cropped-img_5309.jpeg?w=50&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;span&gt;Facts, Fictions &amp;amp; Fantasies&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;																	&lt;div&gt;
										
										
																							&lt;div&gt;Join 628 other subscribers&lt;/div&gt;
																							
											
											
											
											
																						
										
										&lt;p&gt;
											Have a WordPress.com account? &lt;a href=&quot;https://wordpress.com/log-in?redirect_to=https%3A%2F%2Fr-login.wordpress.com%2Fremote-login.php%3Faction%3Dlink%26back%3Dhttps%253A%252F%252Fmymindmappings.com%252F2026%252F09%252F26%252Fweekend-writing-prompt-glowing-elixer%252F&quot;&gt;Log in now.&lt;/a&gt;										&lt;/p&gt;
									&lt;/div&gt;
																	&lt;/div&gt;
							&lt;/div&gt;
						&lt;/li&gt;
									&lt;li&gt;
					
					&lt;div&gt;
						&lt;div&gt;&lt;a href=&quot;https://mymindmappings.com&quot;&gt;&lt;img src=&quot;https://mymindmappings.com/wp-content/uploads/2024/08/cropped-img_5309.jpeg?w=50&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;span&gt;Facts, Fictions &amp;amp; Fantasies&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;a href=&quot;https://wp.me/pfZzxd-voU&quot;&gt;&lt;span&gt;Copy shortlink&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;https://wordpress.com/reader/blogs/236319647/posts/120708&quot;&gt;&lt;span&gt;View post in Reader&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;a href=&quot;https://subscribe.wordpress.com/&quot;&gt;&lt;span&gt;Manage subscriptions&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;https://wordpress.com/start/&quot;&gt;&lt;span&gt;Sign up&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;https://wordpress.com/log-in?redirect_to=https%3A%2F%2Fr-login.wordpress.com%2Fremote-login.php%3Faction%3Dlink%26back%3Dhttps%253A%252F%252Fmymindmappings.com%252F2026%252F09%252F26%252Fweekend-writing-prompt-glowing-elixer%252F&quot;&gt;&lt;span&gt;Log in&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;a href=&quot;https://wordpress.com/abuse/?report_url=https://mymindmappings.com/2026/09/26/weekend-writing-prompt-glowing-elixer/&quot;&gt;&lt;span&gt;Report this content&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;div&gt;&lt;span&gt;Collapse this bar&lt;/span&gt;&lt;/div&gt;					&lt;/div&gt;
				&lt;/li&gt;
			&lt;/ul&gt;
		&lt;/div&gt;
		

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

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

								
							&lt;/span&gt;
						
											&lt;/div&gt;
				&lt;/div&gt;
				&lt;div&gt;
					&lt;div&gt;
						&lt;div&gt;
							
						&lt;/div&gt;
						&lt;div&gt;
															&lt;div&gt;
									&lt;span&gt;Loading Comments...&lt;/span&gt;
								&lt;/div&gt;
								
								&lt;div&gt;
									
									
																														
												Write a Comment...
												
												&lt;div&gt;
													&lt;div&gt;
																													
																Email
																
															
															
																Name
																
															
															
																Website
																
															
																											&lt;/div&gt;
													
												&lt;/div&gt;
											
																											&lt;/div&gt;
													&lt;/div&gt;
						&lt;div&gt;
							&lt;div&gt;
								&lt;div&gt;
									
								&lt;/div&gt;

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

		&lt;/div&gt;
		
	
				
	














	
	&lt;div&gt;&lt;div&gt;&lt;span&gt;%d&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
	 


		 	



&lt;/body&gt;</content:encoded>
</item>
<item>
<title>Open Sourcing our Bluesky Dataplane</title>
<link>https://bitcrowd.dev/open-sourcing-our-bluesky-dataplane</link>
<guid isPermaLink="false">KNfhW1tWvbvN1s7AOY92M2iPkIB6NCMda2hv1Q==</guid>
<pubDate>Sat, 26 Sep 2026 21:12:29 +0000</pubDate>
<description>We built a proof of concept dataplane. Itʼs open source now.</description>
<content:encoded>&lt;p&gt;This is our AT Protocol journey here so far:&lt;br/&gt;Starting with a &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;toolkit to evaluate Blueskyʼs open source dataplane&lt;/a&gt;, &lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane&quot;&gt;we picked Elixir&lt;/a&gt; from all the languages in our toolbelt to build a proof of concept to &lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir&quot;&gt;serve timelines&lt;/a&gt;.
We still owe you the code.
Today, we are open sourcing our proof of concept of a dataplane.&lt;/p&gt;&lt;h2&gt;The Closed Core of Bluesky&lt;a href=&quot;https://bitcrowd.dev/open-sourcing-our-bluesky-dataplane#the-closed-core-of-bluesky&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We talked about this &lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-component-nobody-knows-about&quot;&gt;before&lt;/a&gt;:&lt;br/&gt;Itʼs great that Bluesky is running &lt;em&gt;mostly&lt;/em&gt; on open source components.
However, they draw a line at the dataplane that sits at the core of the system.
Although Bluesky provides an open source implementation of the dataplane, this is not what they run in production.
They use a second, closed source variant based on ScyllaDB because only this implementation is ready for the production scale of Bluesky.&lt;/p&gt;&lt;p&gt;Itʼs a pity because we already see interoperable alternatives to Bluesky emerging, delivering on the promise of &lt;a href=&quot;https://atproto.com/blog/protocol-check-in-fall-2025#hard-decentralization&quot;&gt;&lt;em&gt;hard decentralization&lt;/em&gt;&lt;/a&gt;.
Members of the Eurosky team have even been invited to the &lt;a href=&quot;https://www.greens-efa.eu/en/article/event/eu-tech-stars-summit&quot;&gt;European Parliament&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Running alternative networks should be as easy as possible but we see each of these projects reimplementing their dataplane.
There is a need for an open source dataplane implementation that will sustain the load when the network grows.&lt;/p&gt;&lt;p&gt;We built our proof of concept in Elixir, for &lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-deciding-factor-fan-out-as-code-not-infrastructure&quot;&gt;many reasons&lt;/a&gt;.
Most notably, itʼs just an excellent runtime to build such systems, while we can always mitigate its weakness in raw compute performance by bringing in native code with a NIF.&lt;/p&gt;&lt;p&gt;You can find the repository here: &lt;a href=&quot;https://github.com/bitcrowd/dataplane_elixir/&quot;&gt;https://github.com/bitcrowd/dataplane_elixir/&lt;/a&gt;&lt;br/&gt;We invite you to evaluate its performance with &lt;a href=&quot;https://github.com/bitcrowd/ATProto_firehose_simulator/&quot;&gt;our simulator project&lt;/a&gt;.&lt;/p&gt;&lt;div&gt;&lt;div&gt;warning&lt;/div&gt;&lt;div&gt;&lt;p&gt;Remember, it is a proof of concept, it is not a production ready implementation.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&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>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>
</channel>
</rss>
