<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://blog.arkency.com/">
  <id>https://blog.arkency.com/</id>
  <title>Hi, we're Arkency</title>
  <updated>2026-08-26T14:40:52Z</updated>
  <link rel="alternate" href="https://blog.arkency.com/" type="text/html"/>
  <link rel="self" href="https://blog.arkency.com/atom.xml" type="application/atom+xml"/>
  <author>
    <name>Arkency</name>
    <uri>https://arkency.com</uri>
  </author>
  <entry>
    <id>tag:blog.arkency.com,2026-08-26:/we-are-all-juniors-again/</id>
    <title type="html">We are all juniors again</title>
    <published>2026-08-26T14:40:52Z</published>
    <updated>2026-08-26T14:40:52Z</updated>
    <author>
      <name>Tomasz Stolarczyk</name>
      <uri>https://blog.arkency.com/authors/tomasz-stolarczyk/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/we-are-all-juniors-again/" type="text/html"/>
    <content type="html">&lt;h1 id="we_are_all_juniors_again"&gt;We are all juniors again&lt;/h1&gt;
&lt;p&gt;A long, long time ago, when &lt;a href="https://en.akinator.com/"&gt;Akinator&lt;/a&gt; was the peak of &amp;ldquo;advanced AI&amp;rdquo; on the internet, before services became micro, and deployments meant sending a file directly from a local computer via FTP to a server, I was introduced to the Dreyfus Skill Model. As a junior dev back then, I liked it because it explained why my senior and architect colleagues could sometimes make impactful decisions intuitively, while I, on the contrary, was expecting step-by-step instructions on how to convert HTML form input into a database row.&lt;/p&gt;

&lt;p&gt;In short, the Dreyfus Skill Model describes that when learning a new skill, a learner goes through 5 stages to acquire it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Novice. We need/expect step-by-step instructions and rules here, and we have problems in situations that don&amp;rsquo;t follow them.&lt;/li&gt;
&lt;li&gt;Advanced beginner. We start to recognize some situation-specific nuances and use our experience instead of just rules.&lt;/li&gt;
&lt;li&gt;Competent. We consciously define goals and priorities and plan our actions according to the situation.&lt;/li&gt;
&lt;li&gt;Proficient. We intuitively recognize what the situation requires but consciously decide how to respond.&lt;/li&gt;
&lt;li&gt;Expert. We intuitively understand the situation and respond without consciously considering each step.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I&amp;rsquo;m pretty sure that depending on how long you&amp;rsquo;ve been in IT, you may find yourself in one of those stages.&lt;/p&gt;

&lt;p&gt;And now, getting back to AI and especially coding agents and support for the whole SDLC, it&amp;rsquo;s another skill, whether we want it or not, that we at least should know how to deal with. The good news is that whether you are a 20-, 30-, or 40-year veteran in the industry, or just joining, we are pretty much on the same level. This allows us not to know things, to experiment, to ask questions, to copy what works, and to change our views and opinions every week. This is what novices/beginners do, and I definitely feel I&amp;rsquo;m in those phases again. And I&amp;rsquo;m pretty sure that&amp;rsquo;s not only me.&lt;/p&gt;

&lt;p&gt;Surprisingly often, when digging deeper into those &amp;ldquo;high-level AI concepts&amp;rdquo;, conversations end up on something like &amp;ldquo;well, I have a skill/MD&amp;rdquo;, or &amp;ldquo;I have a nice prompt that does the job&amp;rdquo;. And that&amp;rsquo;s totally fine, as we are all learning how to use those tools and what works in the whole SDLC. What I&amp;rsquo;m trying to highlight is that instead of hearing about &amp;ldquo;10x productivity increase&amp;rdquo;, &amp;ldquo;building another harness&amp;rdquo;, &amp;ldquo;running a swarm of agents&amp;rdquo;, or &amp;ldquo;AI changing someone&amp;rsquo;s life&amp;rdquo;, I would actually want to hear how it&amp;rsquo;s done. With details. And those details may be a single line of text in CLAUDE.md or a short explanation of this &amp;ldquo;obviously known feature&amp;rdquo;, since people expect exactly this kind of detail, because everyone feels behind.&lt;/p&gt;

&lt;p&gt;We are in a place where, just a year ago, &lt;a href="https://survey.stackoverflow.co/2025/ai"&gt;the Stack Overflow Developer Survey 2025&lt;/a&gt; found that 76% of developers weren&amp;rsquo;t planning to use AI for deployment and monitoring, and 69% weren&amp;rsquo;t planning to use it for project planning. If that&amp;rsquo;s where we were as an industry a year ago, not many seniors around now 😉&lt;/p&gt;

&lt;p&gt;As IDEs improved over the years, they sped up our coding by providing autocompletion, code navigation, etc. With AI coding agents, it&amp;rsquo;s different — quite a lot of people say that our time will shift from coding to other activities. And ironically, I bet you&amp;rsquo;ve already seen projects that have done less to simplify monitoring and releases for their devs than they&amp;rsquo;re now doing for their machines.&lt;/p&gt;

&lt;p&gt;Building a harness? Show me if it&amp;rsquo;s just an instruction to your agent &amp;ldquo;ask user for review&amp;rdquo;, &lt;a href="https://code.claude.com/docs/en/hooks"&gt;Claude Code hooks&lt;/a&gt;, or maybe &lt;a href="https://blog.arkency.com/getting-nondeterministic-agent-into-deterministic-guardrails/"&gt;good old pre-commit hooks that you are sure will execute&lt;/a&gt; 😉&lt;/p&gt;

&lt;p&gt;Running your agent army? Let me know if those are your old laptops or VPSs and whether you tmux to them one by one, delegating some tasks, or maybe use &lt;a href="https://code.claude.com/docs/en/agent-teams"&gt;Claude&amp;rsquo;s Agent Teams&lt;/a&gt; or &lt;a href="https://ampcode.com/news/agents-in-orbs"&gt;AMP&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fewer slogans, more specifics.&lt;/p&gt;

&lt;p&gt;This is especially important now, when a lot changes literally every week, meaning there are new areas to discover and new ways to work with those tools. Their non-deterministic nature adds some complexity too. There are no best practices, so whatever works for you today is worth sharing. You don&amp;rsquo;t have to worry it will be outdated next month. You don&amp;rsquo;t have to worry about being wrong. On the other hand, it&amp;rsquo;s not out of the question that what you are doing now will stand the test of time and become a best practice sometime 🙂&lt;/p&gt;

&lt;p&gt;We are all juniors, and let&amp;rsquo;s have some fun with it!&lt;/p&gt;

&lt;p&gt;That said, and as this whole post was about sharing knowledge, here you have some links from us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.arkency.com/maintaining-an-organizational-knowledge-graph-with-an-llm-and-event-sourcing/"&gt;Piotr about maintaining an organizational knowledge graph with an LLM and event sourcing.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.arkency.com/getting-nondeterministic-agent-into-deterministic-guardrails/"&gt;Łukasz about getting a nondeterministic agent into deterministic guardrails.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Mavtkt79lpA"&gt;Szymon on RBQ 2026 about mutation testing becoming critical in the era of AI agents.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.arkency.com/res-mcp-server/"&gt;Tomek about exploring the Event Store with res-mcp.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Last but not least, if you prefer a condensed, one-to-one experience with Arkencers where we focus on your project and on making your team AI-native in the way we see it, you may be interested in this &lt;a href="https://arkency.com/ai-native-rails/"&gt;workshop&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;See you next time, and remember: together we will all become seniors sometime 🙂&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-08-11:/maintaining-an-organizational-knowledge-graph-with-an-llm-and-event-sourcing/</id>
    <title type="html">Maintaining an organizational knowledge graph with an LLM and event sourcing</title>
    <published>2026-08-11T10:00:00Z</published>
    <updated>2026-08-11T10:00:00Z</updated>
    <author>
      <name>Piotr Jurewicz</name>
      <uri>https://blog.arkency.com/authors/piotr-jurewicz/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/maintaining-an-organizational-knowledge-graph-with-an-llm-and-event-sourcing/" type="text/html"/>
    <content type="html">&lt;h1 id="maintaining_an_organizational_knowledge_graph_with_an_llm_and_event_sourcing"&gt;Maintaining an organizational knowledge graph with an LLM and event sourcing&lt;/h1&gt;
&lt;p&gt;Organizations are surprisingly good at forgetting.&lt;br&gt;
Decisions are made on calls, insights get buried in Slack threads, and a month later no one remembers why things are the way they are.&lt;/p&gt;

&lt;!-- more --&gt;

&lt;p&gt;Arkency is no exception.&lt;br&gt;
Weekly calls, ad-hoc meetings, our book clubs, Slack discussions, GitHub mentions, email inbox - we could use some support in organizing all those signals.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://rubycommunityconference.com"&gt;Ruby Community Conference 2026&lt;/a&gt; happened in March.&lt;br&gt;
In Kraków, &lt;strong&gt;Obie Fernandez&lt;/strong&gt; showed some parts of his NEXUS system.&lt;br&gt;
He had already described it &lt;a href="https://obie.medium.com/what-used-to-take-months-now-takes-days-cc8883cc21e9"&gt;on his blog&lt;/a&gt; back in January, but the conference was where I first came across it.&lt;br&gt;
That was the push I needed to start building our own software.&lt;/p&gt;

&lt;p&gt;When it was already taking shape, Andrej Karpathy published his &lt;a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f"&gt;LLM Wiki&lt;/a&gt; note.&lt;br&gt;
Instead of a RAG system rediscovering your documents on every query, an LLM incrementally maintains a persistent wiki: interlinked markdown pages, immutable sources underneath, and a human curating the loop.&lt;br&gt;
It was quite exciting to realize I was working on something that had just become one of the hottest topics in the industry.&lt;/p&gt;

&lt;p&gt;We ended up with &lt;em&gt;Planet Arkency&lt;/em&gt; - a multi-tenant &lt;strong&gt;knowledge graph with a closed ontology&lt;/strong&gt;, built on Rails Event Store.&lt;br&gt;
In this post, I want to walk you through the design decisions I made.&lt;/p&gt;
&lt;h2 id="unstructured_input_is_where_llms_actually_shine"&gt;Unstructured input is where LLMs actually shine&lt;/h2&gt;
&lt;p&gt;For structured data, you could have built such a system like twenty years ago.&lt;br&gt;
Webhooks, forms, integrations - parsing structured input into a graph is a solved problem.&lt;br&gt;
But the most interesting knowledge lives in the input no parser could ever handle: meeting transcripts, Slack discussions, emails, or anything coming from an integration nobody has built yet.&lt;br&gt;
This is where LLMs changed the game for us.&lt;/p&gt;

&lt;p&gt;Everything flows into the system through a single ingestion endpoint.&lt;br&gt;
Transcripts, Slack threads someone flagged with a dedicated emoji reaction, emails arriving at a bridge inbox, RSS feeds, calendar invites, personal notes.&lt;br&gt;
&lt;strong&gt;We don&amp;rsquo;t even write code for the integration points.&lt;/strong&gt;&lt;br&gt;
Tools like Zapier or n8n watch the sources and push the content to that single endpoint.&lt;/p&gt;

&lt;p&gt;Every ingested piece of content then goes through an &lt;strong&gt;extraction&lt;/strong&gt; - the heart of the system.&lt;br&gt;
An LLM reads the content and works out what it means for our knowledge: which entities appear in it, what we learned about them, and how they relate to each other.&lt;br&gt;
Most of this post is about what happens around that single step.&lt;/p&gt;
&lt;h2 id="why_a_graph_"&gt;Why a graph?&lt;/h2&gt;
&lt;p&gt;The same names keep coming back in our conversations: people, projects, clients, tools, decisions.&lt;br&gt;
What changes from week to week is what we know about them and how they relate to each other.&lt;br&gt;
That maps naturally to a graph: entities with attributes, connected by typed relations.&lt;br&gt;
Who works on what.&lt;br&gt;
Who made which decision, and when.&lt;br&gt;
Which project depends on which tool.&lt;/p&gt;

&lt;p&gt;This is where we differ most from the LLM Wiki approach.&lt;br&gt;
In a wiki, the fact that someone works on some project is written down in a sentence on a page, at best with a link between the two pages.&lt;br&gt;
The knowledge is there, but only a reader can make use of it.&lt;br&gt;
In a typed graph, &lt;code&gt;person --works_on--&amp;gt; project&lt;/code&gt; is a piece of data: you can query it, traverse it, count it.&lt;/p&gt;

&lt;p&gt;The graph itself sits on PostgreSQL: a &lt;code&gt;nodes&lt;/code&gt; table, an &lt;code&gt;edges&lt;/code&gt; table with a unique &lt;code&gt;(source, target, relation)&lt;/code&gt; triple, &lt;code&gt;jsonb&lt;/code&gt; attributes on both - no rocket science here.&lt;br&gt;
Dedicated graph databases (Neo4j, triple stores like the one NEXUS uses) could be a better fit for some specific workloads, like deep multi-hop traversal.&lt;br&gt;
But nothing above is Postgres-specific.&lt;br&gt;
A schema this plain is what makes the storage an actual detail - there is little to port when you write another adapter for the data layer.&lt;/p&gt;
&lt;h3 id="the_ontology"&gt;The ontology&lt;/h3&gt;
&lt;p&gt;Which kinds of nodes and relations may exist is defined in an &lt;strong&gt;ontology&lt;/strong&gt;, stored in a plain YAML file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# from config/ontology.yml&lt;/span&gt;
&lt;span class="na"&gt;node_kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;person&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;team&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;member,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;candidate,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;client&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;contact,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;external&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;person"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;decision&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;requiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;group&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;casual&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;suggestions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;idea"&lt;/span&gt;
&lt;span class="na"&gt;edge_relations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;relation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;works_on&lt;/span&gt;
    &lt;span class="na"&gt;signature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;person&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--works_on--&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;project"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The ontology is closed - if a kind or relation is not on the list, the model cannot use it.&lt;br&gt;
Initially I was thinking about an open ontology, where the LLM could introduce its own types.&lt;br&gt;
It brought complete chaos into the graph surprisingly fast.&lt;br&gt;
In my opinion, it is better to tell the model upfront what to look for.&lt;/p&gt;
&lt;h3 id="not_one_graph__but_many"&gt;Not one graph, but many&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;The organizational knowledge graph&amp;rdquo;&lt;/em&gt; suggests one universal graph for all different purposes.&lt;br&gt;
We don&amp;rsquo;t believe in that, and DDD practitioners will recognize why.&lt;/p&gt;

&lt;p&gt;We use a multi-tenant architecture to maintain separate graphs with their own ontologies, which really means their own ubiquitous languages.&lt;br&gt;
Our internal Arkency graph speaks in people, projects and decisions - a domain quite close to a CRM.&lt;br&gt;
The graph we run as Rails Event Store maintainers speaks in releases, known problems and community content:&lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/arkency-planet/ontology.png" width="100%"&gt;&lt;/p&gt;

&lt;p&gt;Different domains, different vocabularies, the same machinery underneath.&lt;br&gt;
The boundaries of a bounded context tell you where one graph ends and another begins.&lt;/p&gt;
&lt;h2 id="what_comes_out_of_an_extraction"&gt;What comes out of an extraction&lt;/h2&gt;
&lt;p&gt;The ontology is rendered into the extraction prompt as markdown tables and into the schema as enums.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight erb"&gt;&lt;code&gt;(from app/lib/prompts/extraction.md.erb)

You are an organizational knowledge analyst for &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="no"&gt;Tenancy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;. We are building an internal knowledge graph.

Extract a knowledge graph from the provided content: nodes and edges. The graph should allow full reconstruction of the provided content.

## Nodes

Each node has: name, kind, short_description, description, attrs (optional key-value pairs).

Allowed kinds:

| kind | what it represents | typical attrs |
|---|---|---|
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;ontology&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node_kinds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;
| &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attrs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"—"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; |
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;

## Edges

Each edge has: source, target, relation, context, attrs (optional key-value pairs).

Allowed relations:

| relation | source kind | target kind | hint | attrs |
|---|---|---|---|---|
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;ontology&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edge_relations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt;
  &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Ontology&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;source_kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:source&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;" / "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;target_kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:target&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;" / "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"—"&lt;/span&gt;
  &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attrs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"—"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;
| &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"relation"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;source_kind&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;target_kind&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; | &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; |
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;

...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each extraction ends with the model returning &lt;strong&gt;one structured result&lt;/strong&gt;: the entities it found in the content, the relations between them, and how the existing graph should change to reflect them.&lt;br&gt;
We use &lt;a href="https://rubyllm.com/chat/#using-rubyllmschema-recommended"&gt;RubyLLM&amp;rsquo;s schema support&lt;/a&gt; for that.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# from app/lib/extraction_result_schema.rb&lt;/span&gt;
&lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="ss"&gt;:nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Entities to create or update. Each name must be unique — no duplicate nodes."&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;enum: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"existing"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"'existing' iff the node was returned by search_nodes/list_nodes_by_kind/get_node_edges and you are reusing it. 'new' if you are introducing it. The system verifies the canonical name and aborts on mismatch."&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Entity name. For 'existing' nodes use the EXACT canonical name from the tool call result. For 'new' nodes the canonical name you are introducing."&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:new_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Optional. Set ONLY for 'existing' nodes when the content reveals a more explicit canonical form (e.g. acronym → full term, diminutive → full name). The node is looked up by `name` and renamed to `new_name`."&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Must be one of: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;kind_names&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:short_description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Stable synthesis of what this entity is (for search). General and identity-focused, not episode-specific. Max 15 words."&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"For new nodes: brief description based on the content. For existing nodes: synthesize prior description with new information. Rewriting for clarity is fine, but preserve prior facts."&lt;/span&gt;
    &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="ss"&gt;:attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Key-value attributes. Only include what is known from the content."&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="ss"&gt;:aliases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Optional. Alternative surface forms (diminutives, acronyms, full vs short forms) under which this entity was referred to in the content, or — when renaming via `new_name` — the old canonical if it remains a valid surface form. Only include NEW aliases not already present on the existing node. An alias is the SAME entity under another name — never a separate entity."&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="ss"&gt;:edges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"ALL relationships. Be thorough and precise."&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Source node name (exact match — existing or newly created)"&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Target node name (exact match — existing or newly created)"&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:relation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Must be one of: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;relation_names&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Briefly explain why this relationship exists, grounded in the content"&lt;/span&gt;
    &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="ss"&gt;:attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="s2"&gt;"Key-value attributes for this edge (e.g. since, weight)"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Actual data operations (create or update, with the exact field-level diff) are derived server-side.&lt;br&gt;
We load or initialize an ActiveRecord model, assign what the LLM returned, and let dirty tracking do the rest:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# from app/handlers/propose_graph_change.rb&lt;/span&gt;
&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_initialize_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;enforce_status!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# raises when the model's new/existing claim disagrees with the DB&lt;/span&gt;

&lt;span class="n"&gt;was_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_record?&lt;/span&gt;
&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;short_description: &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;description: &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;attrs: &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;except&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;op: &lt;/span&gt;&lt;span class="n"&gt;was_new&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"create"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;node_id: &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persisted?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;changes: &lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;node.changes&lt;/code&gt; gives us &lt;code&gt;{field =&amp;gt; [before, after]}&lt;/code&gt; pairs for free, and this before/after snapshot becomes the wire format of the graph change proposal.&lt;br&gt;
Edges get exactly the same treatment - looked up by their &lt;code&gt;(source, target, relation)&lt;/code&gt; triple and diffed with dirty tracking.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/arkency-planet/extraction-result.png" width="100%"&gt;&lt;/p&gt;

&lt;p&gt;We also don&amp;rsquo;t blindly trust what the LLM claims.&lt;br&gt;
It has to declare each node as &lt;code&gt;new&lt;/code&gt; or &lt;code&gt;existing&lt;/code&gt;, and a validator cross-checks it against the database.&lt;br&gt;
On mismatch, the LLM gets natural-language feedback and another attempt on the same conversation.&lt;/p&gt;
&lt;h2 id="identity_resolution_is_the_hard_part"&gt;Identity resolution is the hard part&lt;/h2&gt;
&lt;p&gt;I just wrote that the model has to declare each node as &lt;code&gt;new&lt;/code&gt; or &lt;code&gt;existing&lt;/code&gt;.&lt;br&gt;
But how would it know?&lt;br&gt;
Do we load the whole graph into LLM context?&lt;br&gt;
No - this is where &lt;strong&gt;tool calls&lt;/strong&gt; come in.&lt;/p&gt;

&lt;p&gt;And it is harder than a simple lookup.&lt;br&gt;
&amp;ldquo;Piotrek&amp;rdquo;, &amp;ldquo;Piotr Jurewicz&amp;rdquo; and whatever Zoom&amp;rsquo;s transcription makes out of my name are the same person.&lt;br&gt;
If you create a node per surface form, your graph turns into garbage within a week.&lt;/p&gt;

&lt;p&gt;We handle it on three levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the model must look before it writes.&lt;/strong&gt;&lt;br&gt;
During extraction it has access to read-only tools like &lt;code&gt;search_nodes&lt;/code&gt; or &lt;code&gt;get_node_edges&lt;/code&gt;.&lt;br&gt;
The extraction prompt is explicit about it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(from app/lib/prompts/extraction.md.erb)
- Before creating any node, use search_nodes to check if it already exists. (...)
- If search_nodes returns no results, the node does not exist yet — proceed to create it. (...)
- If search_nodes returns ambiguous results, or you need broader context to make extraction decisions, use get_node_edges to inspect the node's connections.
- After finding nodes with search_nodes, use get_node_edges to see their existing relationships before deciding how to connect them.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Second, aliases are the identity mechanism.&lt;/strong&gt;&lt;br&gt;
Each node has one canonical name and any number of aliases.&lt;br&gt;
The schema instructs the model that an alias is the same entity under another name - never a separate entity.&lt;br&gt;
When the content reveals a better canonical form, the model sets &lt;code&gt;new_name&lt;/code&gt; and the old name stays as an alias, so future fuzzy searches still resolve it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the search is hybrid.&lt;/strong&gt;&lt;br&gt;
Trigram similarity (pg_trgm with GIN indexes) over node names &lt;em&gt;and&lt;/em&gt; aliases catches misspellings.&lt;br&gt;
Embedding search catches semantic matches which share no characters:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# from app/models/node.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;limit: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# fuzzy match on canonical names and aliases, powered by pg_trgm&lt;/span&gt;
  &lt;span class="n"&gt;by_name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"similarity(nodes.name, ?) &amp;gt; 0.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;by_alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:aliases&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"similarity(node_aliases.name, ?) &amp;gt; 0.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;trigram_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;union_by_best_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;by_alias&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"bge-m3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;provider: :ollama&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;semantic_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;nearest_neighbors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;distance: &lt;/span&gt;&lt;span class="s2"&gt;"cosine"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;neighbor_distance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;SEMANTIC_THRESHOLD&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;merge_and_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trigram_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;semantic_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The embeddings come from a self-hosted &lt;code&gt;bge-m3&lt;/code&gt; model on Ollama, stored in pgvector.&lt;/p&gt;
&lt;h2 id="every_fact_has_a_source"&gt;Every fact has a source&lt;/h2&gt;
&lt;p&gt;A graph edited by an AI is only trustworthy if you can audit every change.&lt;br&gt;
For every node and edge we can answer: which extraction created you, which extractions updated you, and what exactly changed each time.&lt;/p&gt;

&lt;p&gt;Provenance lives in join tables (&lt;code&gt;node_extractions&lt;/code&gt; and &lt;code&gt;edge_extractions&lt;/code&gt;): one row per extraction and entity pair, holding the operation, the status, and the field-level &lt;code&gt;diff&lt;/code&gt; produced by the dirty tracking described before.&lt;br&gt;
Starting from any node, you can walk back through these rows to the extraction that touched it, and from the extraction to the ingested content it was based on.&lt;br&gt;
Every fact in the graph traces back to its source.&lt;/p&gt;

&lt;p&gt;We also record something we call the &lt;strong&gt;read set&lt;/strong&gt;.&lt;br&gt;
Every tool call the model makes during extraction is published as an &lt;code&gt;ExtractionToolCalled&lt;/code&gt; event and projected into &lt;code&gt;tool_invocations&lt;/code&gt;, linked to the nodes and edges the call returned.&lt;br&gt;
So we know not only what an extraction wrote, but also what it read before deciding.&lt;br&gt;
When you wonder &amp;ldquo;why did the model merge these two people?&amp;rdquo;, the answer is on the extraction page: here is the search it ran, and here is what came back.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/arkency-planet/tool-calls.png" width="100%"&gt;&lt;/p&gt;

&lt;p&gt;Each node&amp;rsquo;s page shows its full history: created in, last updated in, read by N extractions.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/arkency-planet/provenance.png" width="100%"&gt;&lt;/p&gt;
&lt;h2 id="keeping_an_eye_on_the_costs"&gt;Keeping an eye on the costs&lt;/h2&gt;
&lt;p&gt;Besides auditing changes in the graph, we also track how much each extraction costs: token usage and the resulting price.&lt;br&gt;
When you work with an LLM API, it is worth keeping a finger on the pulse here.&lt;br&gt;
A transcript of a few hours of conversation, processed in multiple rounds interleaved with tool calls, can generate significant costs.&lt;br&gt;
&lt;a href="https://rubyllm.com/chat/#anthropic-prompt-caching"&gt;Prompt caching&lt;/a&gt; helps a lot - the system prompt and the content stay identical between rounds, so most of the input is billed at the cache-read rate.&lt;/p&gt;

&lt;p&gt;The exact numbers depend on the model you run the extraction on, but most of ours cost well under a dollar.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/arkency-planet/extractions-cost.png" width="100%"&gt;&lt;/p&gt;
&lt;h2 id="human_in_the_loop"&gt;Human in the loop&lt;/h2&gt;
&lt;p&gt;We don&amp;rsquo;t let the LLM write to the graph directly.&lt;br&gt;
Extraction produces a &lt;strong&gt;proposal&lt;/strong&gt; with the before/after diffs, and applying it to the graph is a separate step.&lt;/p&gt;

&lt;p&gt;Proposals can sit in a review window before they get applied.&lt;br&gt;
As soon as an extraction completes, we get a short summary of it on Slack.&lt;br&gt;
A human can inspect the diff, apply it early, or just let it flow after the configured delay.&lt;/p&gt;

&lt;p&gt;Time passes between propose and apply, so the graph may have moved in the meantime.&lt;br&gt;
When the current state no longer matches what the proposal was based on, the apply stops and the affected rows get marked as conflicted, with a human-readable explanation.&lt;/p&gt;
&lt;h2 id="event_sourcing_ties_it_all_together"&gt;Event sourcing ties it all together&lt;/h2&gt;
&lt;p&gt;You may have noticed that every mechanism above was described in terms of events.&lt;br&gt;
Well, this is an Arkency blog after all.&lt;/p&gt;

&lt;p&gt;The whole pipeline is an event flow: &lt;code&gt;TranscriptIngested&lt;/code&gt; → &lt;code&gt;ExtractionRequested&lt;/code&gt; → &lt;code&gt;KnowledgeExtracted&lt;/code&gt; → &lt;code&gt;GraphChangeProposed&lt;/code&gt; → &lt;code&gt;GraphChangeApplied&lt;/code&gt; (or &lt;code&gt;GraphChangeConflicted&lt;/code&gt;).&lt;br&gt;
Two small aggregates guard the invariants: one per ingestion (no two concurrent extractions of the same content), one per extraction (the propose → apply state machine).&lt;br&gt;
Everything you see in the UI (ingestions, extractions, diffs, tool invocations) is a read model built from these events.&lt;/p&gt;

&lt;p&gt;In this architecture, the review window is just one more state in the aggregate&amp;rsquo;s state machine, and provenance is just one more read model built from an event we already had.&lt;br&gt;
I cannot understand people claiming that event sourcing makes things more complex ;)&lt;/p&gt;
&lt;h2 id="the_graph_can_feed_itself"&gt;The graph can feed itself&lt;/h2&gt;
&lt;p&gt;One feature shows the value of a uniform pipeline well.&lt;br&gt;
From any node you can request research.&lt;br&gt;
A job asks a model equipped with Anthropic&amp;rsquo;s server-side &lt;code&gt;web_search&lt;/code&gt; and &lt;code&gt;web_fetch&lt;/code&gt; tools to compile a brief about the entity:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# from app/jobs/research_topic.rb&lt;/span&gt;
&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyLLM&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="no"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;tools: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"web_search_20250305"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"web_search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;max_uses: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"web_fetch_20250910"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"web_fetch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;max_uses: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ResearchBriefSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The prompt grounds the research in what the graph already knows about the entity, and tells the model when to give up:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# from app/jobs/research_topic.rb
Research "#{topic}". Use web_search and web_fetch as needed to gather facts.

In our knowledge base this entity is currently described as:
- Kind: ...
- Short description: ...
- Attributes: ...

When you can produce a useful brief, return status="completed" and put the
brief in `brief` as Markdown. (...) Cover identity, key facts a knowledgeable
reader should know, recent activity worth recording, and relationships to
other named entities. Include source URLs inline next to claims that come
from a specific page. Keep it factual; do not speculate.

Return status="aborted" instead — with `abort_reason` naming the specific
problem — when any of these holds:
- The topic is ambiguous and you cannot confidently pick the intended
  interpretation from the disambiguation context above.
- You cannot find substantive, verifiable information about this exact
  entity (...)

Do not pad an aborted result with related-but-different information.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The resulting brief is not applied to the graph directly.&lt;br&gt;
It gets published as a regular &lt;code&gt;TranscriptIngested&lt;/code&gt; event with its own kind, and flows through the same extraction, proposal and review pipeline as any other input.&lt;/p&gt;
&lt;h2 id="the_graph_speaks_mcp"&gt;The graph speaks MCP&lt;/h2&gt;
&lt;p&gt;The graph is not locked inside its own UI.&lt;br&gt;
We expose it over &lt;strong&gt;MCP&lt;/strong&gt;, so any AI assistant with access to our server can search it by asking questions in natural language - and answer from the graph, with sources.&lt;/p&gt;
&lt;h2 id="final_thoughts"&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;Working on Planet Arkency taught me a lot.&lt;br&gt;
About graphs, about LLMs, and about concepts I had never even heard of before: ontologies, identity resolution, provenance.&lt;br&gt;
I hope some of that knowledge stays with you after reading this post.&lt;/p&gt;

&lt;p&gt;It also reassured me about the tools we have been using at Arkency for years.&lt;br&gt;
Event-driven architecture and Rails Event Store carried this project naturally.&lt;/p&gt;

&lt;p&gt;At the time of writing, the main production Arkency graph holds almost 2000 nodes and over 5200 edges, built by around 300 extractions.&lt;br&gt;
And behind all of that, over 3600 events recording how every single fact got there.&lt;/p&gt;

&lt;p&gt;I still have a head full of ideas on where to take this project next.&lt;/p&gt;

&lt;p&gt;Working with &lt;a href="https://rubyllm.com"&gt;RubyLLM&lt;/a&gt; was a pure pleasure - credits to Carmine Paolino for this gem.&lt;/p&gt;

&lt;p&gt;If you are thinking about organizational memory for your company, or want us to help you build one, &lt;a href="https://arkency.com/hire-us/"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-07-16:/3-example-process-managers-in-rails/</id>
    <title type="html">3 example process managers in Rails</title>
    <published>2026-07-16T09:03:31Z</published>
    <updated>2026-07-16T09:03:31Z</updated>
    <author>
      <name>Andrzej Krzywda</name>
      <uri>https://blog.arkency.com/authors/andrzej-krzywda/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/3-example-process-managers-in-rails/" type="text/html"/>
    <content type="html">&lt;h1 id="3_example_process_managers_in_rails"&gt;3 example process managers in Rails&lt;/h1&gt;
&lt;p&gt;I am writing this blogpost from this beautiful place in the Polish mountains. Our RailsEventStore camp takes place this week. &lt;/p&gt;

&lt;p&gt;&lt;img src="https://blog-arkency.imgix.net/3-example-process-managers/IMG_9894.jpeg?w=768&amp;h=758&amp;fit=max" width="70%"&gt;&lt;/p&gt;

&lt;p&gt;Process managers are meant to map business process into code. The more readable and declarative it is, the better the chance domain experts will understand it.&lt;/p&gt;

&lt;p&gt;Over the last years, we have experimented with several approaches on how to implement process managers in Ruby.&lt;/p&gt;

&lt;p&gt;The foundation here is &lt;a href="https://railseventstore.org"&gt;RailsEventStore&lt;/a&gt; and the architecture where events are published and commands are used to tell what to do next.&lt;/p&gt;

&lt;p&gt;Most business processes can be mapped into some sort of a checklist of requirements and when certain conditions are met we decide (a command) what to do next.&lt;/p&gt;

&lt;p&gt;During the RESCamp I have released a small library - &lt;a href="https://github.com/RailsEventStore/rails_event_store/tree/master/contrib/ruby_event_store-process_manager"&gt;ruby_event_store-process_manager&lt;/a&gt; - which was extracted from the ecommerce project.&lt;/p&gt;
&lt;h2 id="1__releasepaymentonorderexpiration"&gt;1. ReleasePaymentOnOrderExpiration&lt;/h2&gt;
&lt;p&gt;I will start with a process which defines what to do when a payment was authorized but the offer has expired. You can imagine that different businesses have different rules for such situations. BTW, that&amp;rsquo;s why I like to put process managers at the application layer and keep them under app/processes.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Processes&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReleasePaymentOnOrderExpiration&lt;/span&gt;
    &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ProcessManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;subscribes_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PaymentAuthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PaymentReleased&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderRegistered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OfferExpired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderConfirmed&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;act&lt;/span&gt;
      &lt;span class="n"&gt;release_payment&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release?&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PaymentAuthorized&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;payment_authorized: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PaymentReleased&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;payment_authorized: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OfferExpired&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order_expired: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;release_payment&lt;/span&gt;
      &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ReleasePayment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:payment_authorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:order_expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;payment_authorized: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;order_expired: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;release?&lt;/span&gt;
        &lt;span class="n"&gt;payment_authorized&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;order_expired&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Thanks to the ProcessManager certain things are taken care of for free. One of them is the concept of streams and event sourcing. All events which are relevant to this process are grouped into one stream. This stream by convention will be called &lt;code&gt;ReleasePaymentOnOrderExpiration$uuid&lt;/code&gt; but it&amp;rsquo;s easy to override. All the events will be linked to this stream automatically. Thanks to this, the process will be rebuilt by replaying the stream on every new event.
Additionally, if two events for the same process race and hit a version conflict, the state-building is retried once.&lt;/p&gt;

&lt;p&gt;That&amp;rsquo;s what is hidden. &lt;/p&gt;

&lt;p&gt;What we have here is typical sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;events&lt;/li&gt;
&lt;li&gt;state building&lt;/li&gt;
&lt;li&gt;decision (&lt;code&gt;act&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="2__reservation"&gt;2. Reservation&lt;/h2&gt;
&lt;p&gt;Reservation is a common problem to solve in order-related apps. However, every business defines their reservation process differently. 
Imagine a business which sells equipment around a specific topic, let&amp;rsquo;s say photography. You want to buy a camera, a lens and a tripod. All of this is important and if any of them is not available the whole order doesn&amp;rsquo;t make sense -  a camera the customer can&amp;rsquo;t yet use isn&amp;rsquo;t worth shipping&lt;/p&gt;

&lt;p&gt;This business process is implemented here:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Processes&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReservationProcess&lt;/span&gt;
    &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ProcessManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;subscribes_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="no"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OfferAccepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderCancelled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderConfirmed&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;act&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
      &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="ss"&gt;order: :accepted&lt;/span&gt;
        &lt;span class="n"&gt;unavailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reserve_stock&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any?&lt;/span&gt;
          &lt;span class="n"&gt;reject_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;
          &lt;span class="n"&gt;accept_order&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="ss"&gt;order: :cancelled&lt;/span&gt;
        &lt;span class="n"&gt;release_stock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved_product_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="ss"&gt;order: :confirmed&lt;/span&gt;
        &lt;span class="n"&gt;dispatch_stock&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OfferAccepted&lt;/span&gt;
        &lt;span class="n"&gt;order_lines_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_lines&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;ol&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:product_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:quantity&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="ss"&gt;order: :accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;order_lines: &lt;/span&gt;&lt;span class="n"&gt;order_lines_hash&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderCancelled&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order: :cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OrderConfirmed&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order: :confirmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve_stock&lt;/span&gt;
      &lt;span class="n"&gt;unavailable_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
      &lt;span class="n"&gt;reserved_products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Reserve&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;product_id: &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;reserved_products&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;
      &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;InventoryEntry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;InventoryNotAvailable&lt;/span&gt;
        &lt;span class="n"&gt;unavailable_products&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unavailable_products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any?&lt;/span&gt;
        &lt;span class="n"&gt;release_stock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reserved_products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="n"&gt;unavailable_products&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;release_stock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;product_ids&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;product_id: &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dispatch_stock&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Dispatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;product_id: &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;quantity: &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;accept_order&lt;/span&gt;
      &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Fulfillment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RegisterOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unavailable_product_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Pricing&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RejectOffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;order_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reason: &lt;/span&gt;&lt;span class="s2"&gt;"Some products were unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unavailable_product_ids&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:order_lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;order_lines: &lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="ss"&gt;order_lines: &lt;/span&gt;&lt;span class="n"&gt;order_lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserved_product_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order_lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;What is worth noting here is that this business process can result in 5 different commands for 3 different contexts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Pricing::RejectOffer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fulfillment::RegisterOrder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Inventory::Dispatch&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Inventory::Reserve&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Inventory::Release&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is very typical case in complex businesses. A context can be seen as a department in a company - that&amp;rsquo;s one of the DDD metaphors which I enjoy using.&lt;/p&gt;
&lt;h2 id="3__publishing_a_post_on_twitter_like_app"&gt;3. Publishing a post on Twitter-like app&lt;/h2&gt;
&lt;p&gt;This process comes from a different app. Imagine a social media app, where people see their timeline consisting of posts from the people they follow. The process here is simple - make a delivery (posting to a timeline) per each follower, but also include the author timeline.
In the future, we can imagine other rules. For example, we may want to decide to deliver to the online people first. Or maybe we want to push some ads to the timeline when a post seems related. It&amp;rsquo;s nice to have such business rules in one place.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TimelineDeliveryProcess&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ProcessManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;subscribes_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserFollowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserUnfollowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PostPublished&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;act&lt;/span&gt;
    &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;deliver_post_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recipients&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;followers&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:author_id&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserFollowed&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;followers: &lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;followers&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:follower_id&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserUnfollowed&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;followers: &lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;followers&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:follower_id&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PostPublished&lt;/span&gt;
      &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserFollowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;UserUnfollowed&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:followee_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PostPublished&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:author_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deliver_post_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;command_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="no"&gt;Social&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DeliverPostToTimeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;post_id: &lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:post_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;recipient_id: &lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;author: &lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:author&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;body: &lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="no"&gt;ProcessState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:followers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;followers: &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;followers: &lt;/span&gt;&lt;span class="n"&gt;followers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="the_ruby_event_store_process_manager_gem"&gt;The ruby_event_store-process_manager gem&lt;/h2&gt;
&lt;p&gt;As you see, &lt;a href="https://railseventstore.org"&gt;RailsEventStore&lt;/a&gt; together with the &lt;a href="https://github.com/RailsEventStore/rails_event_store/tree/master/contrib/ruby_event_store-process_manager"&gt;ruby_event_store-process_manager&lt;/a&gt; gem, allows to map complex business processes into modules, which encapsulates the logic, but hides the infrastructure.&lt;/p&gt;

&lt;p&gt;In a typical non-event-driven Rails app such processes are usually scattered across models, service objects and callbacks. The alternative approach requires publishing events (which provides event log for free) but the benefits might be worth it.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-06-16:/ingress-is-not-the-owner-of-the-invariant/</id>
    <title type="html">Ingress is not the owner of the invariant</title>
    <published>2026-06-16T13:21:30Z</published>
    <updated>2026-06-16T13:21:30Z</updated>
    <author>
      <name>Szymon Fiedler</name>
      <uri>https://blog.arkency.com/authors/szymon-fiedler/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/ingress-is-not-the-owner-of-the-invariant/" type="text/html"/>
    <content type="html">&lt;h1 id="ingress_is_not_the_owner_of_the_invariant"&gt;Ingress is not the owner of the invariant&lt;/h1&gt;
&lt;p&gt;A polemic with &lt;a href="https://baweaver.com/writing/2026/06/13/rails-sharp-parts-callbacks-are-not-invariants/"&gt;Callbacks Are Not Invariants&lt;/a&gt; by Brandon Weaver.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A disclaimer: I’m a &lt;a href="https://railseventstore.org"&gt;RailsEventStore&lt;/a&gt; maintainer and this article ends up on the Arkency blog — so cards are on the table. Despite this, I’m keeping the core of my argument in pure &lt;code&gt;ActiveRecord&lt;/code&gt;: no step of the reasoning requires &lt;em&gt;RES&lt;/em&gt;. I only show the &lt;em&gt;RES&lt;/em&gt; version at the end, separately, as &amp;ldquo;and this is what it looks like when you’re not typing it in manually&amp;rdquo;. If you’re convinced by the bare-metal &lt;em&gt;AR&lt;/em&gt; reasoning, not the library, that’s what matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;!-- more --&gt;
&lt;h2 id="we_agree_about_the_disease"&gt;We agree about the disease&lt;/h2&gt;
&lt;p&gt;I enjoy reading Brandon’s &lt;em&gt;Rails: The Sharp Parts&lt;/em&gt; series and sending it to the team — it’s one of the better pieces on the sharp edges of Rails that’s come out lately. The one about callbacks is no exception and the diagnosis is spot on. Census &lt;code&gt;_save_callbacks&lt;/code&gt;, which shows eleven entries with two association lines and zero callbacks of its own. A mismatch between what fires before and after &lt;code&gt;COMMIT&lt;/code&gt;. The best sentence in the entire text: &amp;ldquo;a callback is an invariant with a published bypass list&amp;rdquo; — &lt;code&gt;update_all&lt;/code&gt;, &lt;code&gt;insert_all&lt;/code&gt;, &lt;code&gt;update_column&lt;/code&gt; are holes in something that was supposed to &lt;em&gt;always happen&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I’m not here to defend the callbacks. I hate them myself, for the same reasons. The dispute is about the cure.&lt;/p&gt;

&lt;p&gt;And I have one reservation for the cure Brandon proposes — and it doesn’t concern what he built, but how he named it. Because the name will travel beyond a single file, to anyone who copies the template.&lt;/p&gt;
&lt;h2 id="a_name_that_promises_more_than_it_delivers"&gt;A name that promises more than it delivers&lt;/h2&gt;
&lt;p&gt;What the author calls &lt;code&gt;Command&lt;/code&gt; is Fowler’s &lt;code&gt;Transaction Script&lt;/code&gt;. One public call, a private &lt;code&gt;execute&lt;/code&gt;, a procedure orchestrating &lt;code&gt;ActiveRecord&lt;/code&gt; calls. And that’s a good pattern — it’s forty years old and still going strong. The thing is, he calls it a command (suggesting CQRS, which itself announces &lt;em&gt;next time&lt;/em&gt;) and calls &lt;code&gt;ActiveSupport::Notifications&lt;/code&gt; events.&lt;/p&gt;

&lt;p&gt;A name isn’t cosmetic. A name is a mental contract. When 500 engineers work in a monolith — and that’s the scale Weaver explicitly writes for — names are the only documentation anyone reads. If you tell them they’re building &lt;em&gt;commands&lt;/em&gt; and &lt;em&gt;events&lt;/em&gt;, in a year, half the company will think they’re building an event-driven architecture with procedures in their hands. That’s worse than no name, because it installs a mental model that doesn’t match the code.&lt;/p&gt;

&lt;p&gt;And this isn’t about arguing with its values but about appealing to them. Brandon writes about himself that his goal is to make the invisible visible — and that the next person reading the code shouldn’t have to wonder what the author meant. I agree with that with both hands. And that’s precisely why calling a &lt;em&gt;procedure&lt;/em&gt; a &lt;em&gt;command&lt;/em&gt; and a &lt;em&gt;notification&lt;/em&gt; an &lt;em&gt;event&lt;/em&gt; plays against what he wants: it forces the reader to assume a contract — a serializable intent with a separate handler, a persistent domain fact — that doesn’t exist in the code. Correcting the names isn’t a quarrel with Weaver; it’s the completion of his own goal.&lt;/p&gt;
&lt;h2 id="core__ingress_doesn_t_own_the_invariant"&gt;Core: ingress doesn’t own the invariant&lt;/h2&gt;
&lt;p&gt;Single-ingress is correct — one write path per operation, one entry point that owns the state change. But look where the &amp;ldquo;space cannot be reserved twice&amp;rdquo; invariant lives after the refactor:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"seat &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is already reserved"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved?&lt;/span&gt;
    &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;reserved: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Ledger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordReservation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;seat: &lt;/span&gt;&lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The rule is spread across three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a check in runtime (&lt;code&gt;if seat.reserved?&lt;/code&gt;) &lt;/li&gt;
&lt;li&gt;a row lock (&lt;code&gt;with_lock&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a constraint in the database — as Brandon rightly writes elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn&amp;rsquo;t a domain model. It&amp;rsquo;s a &lt;code&gt;Seat.find&lt;/code&gt; + &lt;code&gt;update!&lt;/code&gt; wrapped in a procedure. Infrastructure-first, just repurposed from a callback to a service object. The question &amp;ldquo;who owns the reservation rule&amp;rdquo; still doesn&amp;rsquo;t have a single answer.&lt;/p&gt;

&lt;p&gt;The coupling is now deliberate and visible — but it&amp;rsquo;s still there. One trade-off and one naming issue:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side effects inline in &lt;code&gt;execute&lt;/code&gt;&lt;/strong&gt;. Weaver is explicit about this: &lt;code&gt;announce&lt;/code&gt; belongs to the command body by design, not by accident — subscribers are reserved for observability only and cannot veto a write or introduce ordering dependencies. That&amp;rsquo;s a defensible trade-off: visible coupling beats hidden coupling every time. My claim is narrower: &lt;code&gt;announce&lt;/code&gt; fires after &lt;code&gt;with_lock&lt;/code&gt; commits, so the timing is fine — but if the process dies between commit and &lt;code&gt;deliver_later&lt;/code&gt;, the effect is gone forever. There is nothing to replay from, because the fact was never persisted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;event_name&lt;/code&gt; from the namespace system&lt;/strong&gt;. The event name extracted from &lt;code&gt;module_parent_name&lt;/code&gt; ties the &lt;em&gt;event&lt;/em&gt; taxonomy — that is, &lt;em&gt;the contract&lt;/em&gt; — to the directory structure in the code. Move a module, and the names of events that someone might already be subscribed to change. This is exactly the kind of invisible coupling he&amp;rsquo;s been fighting against throughout this article — only this time it moves a layer higher.&lt;/p&gt;
&lt;h2 id="what_it_looks_like_when_an_invariant_has_an_owner"&gt;What it looks like when an invariant has an owner&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ll show the difference in code, because otherwise, it&amp;rsquo;s just adjectives. Pure &lt;code&gt;ActiveRecord&lt;/code&gt;, without &lt;code&gt;RailsEventStore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;First, the aggregate. It — and only it — decides whether the reservation is allowed and produces the fact. No &lt;em&gt;IO&lt;/em&gt;, no &lt;em&gt;mailer&lt;/em&gt;, no &lt;em&gt;webhook&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Seat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# Invariant lives here. In one place. The method always returns an event,&lt;/span&gt;
  &lt;span class="c1"&gt;# and doesn't fire side effects.&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"seat &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is already reserved"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reserved?&lt;/span&gt;

    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;

    &lt;span class="no"&gt;SeatReserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;seat_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_at: &lt;/span&gt;&lt;span class="n"&gt;reserved_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An &lt;em&gt;event&lt;/em&gt; is a fact. Past tense. Payload is a result, not a request:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;SeatReserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:seat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reserved_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reserved_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;em&gt;handler&lt;/em&gt; is a simple &lt;em&gt;PORO&lt;/em&gt; — one application use-case, one entrance for write. We&amp;rsquo;re keeping Brandon&amp;rsquo;s single-entry discipline because it&amp;rsquo;s good; we&amp;rsquo;re not inheriting his &lt;code&gt;ApplicationCommand&lt;/code&gt; base — its only job was &lt;code&gt;announce&lt;/code&gt;, and that role is now taken by the explicit &lt;code&gt;SeatReserved&lt;/code&gt; with persistent log write and subscribers. There&amp;rsquo;s no constructor, no ivars, and no &lt;code&gt;self.call&lt;/code&gt; to &lt;code&gt;new.call&lt;/code&gt; relay — the handler is so thin that there&amp;rsquo;s nothing to decompose. And that&amp;rsquo;s the point: the rule has been moved to the aggregate, so orchestration remains trivial, and everything happens on the correct side of the commit.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Seats&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReserveSeat&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
      &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;seat&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;   &lt;span class="c1"&gt;# aggregate guards the invariant &lt;/span&gt;
        &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save!&lt;/span&gt;
        &lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# fact in the same transaction&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;Events.publish&lt;/code&gt; does two things, both in the caller&amp;rsquo;s transaction: it writes the event to a persistent log and synchronously calls subscribers. First, the log: append-only, the source of truth, from which you replay:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Persistent event log — source of truth. &lt;/span&gt;
&lt;span class="c1"&gt;# As long as the event lives here, it can be replayed.&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoredEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;occurred_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And the &lt;em&gt;dispatcher&lt;/em&gt;. No magic at all: &lt;code&gt;publish&lt;/code&gt; stores the fact, and then calls &lt;em&gt;handlers&lt;/em&gt; subscribed to the &lt;em&gt;event name&lt;/em&gt; — in the same thread, and in the same transaction:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# publish: persistent fact + sync subscribers, atomically with the state change.&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Events&lt;/span&gt;
  &lt;span class="no"&gt;HANDLERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;StoredEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here&amp;rsquo;s all the persistence I need: &lt;code&gt;StoredEvent.append&lt;/code&gt; runs in the same transaction as &lt;code&gt;seat.save!&lt;/code&gt; — because &lt;code&gt;publish&lt;/code&gt; doesn&amp;rsquo;t open its own transaction, the INSERT inherits the active one from the caller. The fact commits with the state change or not at all. Since the fact remains, any reaction can be recreated — handler retry, log replay. This is the invariant, and you have it without any additional machinery.&lt;/p&gt;

&lt;p&gt;One fair boundary. The subscribers above execute synchronously, in a transaction — which is exactly what you want for reactions intended to be atomic with the fact. But a subscriber doing heavy or external IO (mail, webhook) shouldn&amp;rsquo;t block the transaction; it schedules the work asynchronously, via &lt;code&gt;deliver_later&lt;/code&gt;. And here comes the only gap Weaver worries about: the very scheduling of this asynchronous work isn&amp;rsquo;t atomic by default with the fact&amp;rsquo;s save. This narrow gap — and nothing else — is patched by the transactional outbox. It doesn&amp;rsquo;t patch the persistence of the fact, because that&amp;rsquo;s already taken care of. &lt;/p&gt;

&lt;p&gt;And the mailer and the webhook? They react to the fact. They&amp;rsquo;re not steps in the write path — they&amp;rsquo;re subscribers. You can add, remove, or replace one without touching the &lt;code&gt;ReserveSeat&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;ReservationMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;Webhooks&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Emit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;event: :seat_reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Let&amp;rsquo;s compare both approaches:&lt;/p&gt;

&lt;table&gt;&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Weaver&lt;/th&gt;
&lt;th&gt;Here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invariant&lt;/td&gt;
&lt;td&gt;runtime check + &lt;code&gt;with_lock&lt;/code&gt; + constraint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seat#reserve&lt;/code&gt;, single place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reserve_seat.seats&lt;/code&gt; from namespace, payload = input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seat_reserved&lt;/code&gt;, explicit, payload = result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Side-effects&lt;/td&gt;
&lt;td&gt;inline in &lt;code&gt;announce&lt;/code&gt; (deliberate — observability subscribers can&amp;rsquo;t veto writes)&lt;/td&gt;
&lt;td&gt;subscribers react to the persisted fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;fire-and-forget after commit, can be lost&lt;/td&gt;
&lt;td&gt;event in a transaction, always replayable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;And here&amp;rsquo;s the point I care about most — because it affects the domain I&amp;rsquo;m currently working on: persisting the fact isn&amp;rsquo;t an add-on — it&amp;rsquo;s a day-one invariant. Weaver himself is clear: &lt;em&gt;&amp;ldquo;If you need durable event delivery (guaranteed at-least-once), that&amp;rsquo;s a transactional outbox or CDC, not a subscriber.&amp;rdquo;&lt;/em&gt; So we agree on the destination. Where I part ways is the framing: he presents it as something you reach for at scale. When &lt;code&gt;SeatReserved&lt;/code&gt; is saved in the same transaction as the state change, no effect can be lost forever: since the fact remains, the reaction can always be recreated — retry the handler, replay from the log. Outbox doesn&amp;rsquo;t create this persistence — it only uses it, automating the delivery with an at-least-once guarantee — which means subscribers must be idempotent. You don&amp;rsquo;t persist the fact because you scaled; you persist it from the first INSERT and harden delivery when the async scheduling gap becomes relevant.&lt;/p&gt;
&lt;h2 id="and_if_you_don__39_t_want_to_write_it_by_hand"&gt;And if you don&amp;rsquo;t want to write it by hand&lt;/h2&gt;
&lt;p&gt;The above is on bare &lt;em&gt;AR&lt;/em&gt; intentionally, so the argument can stand up without any library. But you get the same structure off the shelf. &lt;em&gt;Aggregate&lt;/em&gt; with &lt;em&gt;AggregateRoot&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Seat&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;AggregateRoot&lt;/span&gt;

  &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@reserved&lt;/span&gt;
    &lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="no"&gt;SeatReserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;seat_id: &lt;/span&gt;&lt;span class="vi"&gt;@id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="no"&gt;SeatReserved&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="vi"&gt;@reserved&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="vi"&gt;@reserved_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:reserved_by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An invariant in &lt;code&gt;reserve&lt;/code&gt;, a fact in &lt;code&gt;SeatReserved&lt;/code&gt;, a state mutation in &lt;code&gt;on&lt;/code&gt; — and this is an &lt;em&gt;event&lt;/em&gt; that actually lands in the event store, with versioning and replay, not a notification whose name is derived from a namespace. You attach handlers (mailer, webhook) as subscribers exactly as above.&lt;/p&gt;

&lt;p&gt;And delivery? &lt;em&gt;RES&lt;/em&gt; publish is exactly &lt;code&gt;Events.publish&lt;/code&gt;: atomic write to the &lt;em&gt;event store&lt;/em&gt; plus synchronous dispatch, inside the caller&amp;rsquo;s transaction. &lt;code&gt;ruby_event_store-outbox&lt;/code&gt; patches the one gap I named above — transactional scheduling of async handlers — maintained, safe under concurrent workers, covered with mutation tests.&lt;/p&gt;

&lt;p&gt;A complete walkthrough of &lt;em&gt;RES&lt;/em&gt; — aggregates, subscriptions, outbox, strangler on an existing monolith — is a topic for a separate, much longer text. Let me preface this point, as Brandon rightly dislikes rewrites; this path isn&amp;rsquo;t rewriting. It&amp;rsquo;s strangler — precisely the incremental movement it describes, callback by callback, flag by flag. The punchline here is enough: the structure he&amp;rsquo;s approaching is available as a ready-made library, not an exotic one.&lt;/p&gt;
&lt;h2 id="where_weaver_is_right___and_what_i__39_m_not_saying"&gt;Where Weaver is right — and what I&amp;rsquo;m not saying&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m not advocating &amp;ldquo;always event sourcing&amp;rdquo;. That would be precisely the dogmatic approach I combat in people who sell &lt;em&gt;event sourcing&lt;/em&gt; as a religion.&lt;/p&gt;

&lt;p&gt;His &lt;em&gt;Transaction Script&lt;/em&gt; is sufficient for most applications. One team, one write path, reasonable discipline — and a procedure with a single input carries water for years. The Strangler fig + Flipper migrations he describes are really good. Normalizes for pure transformations — agree. Constraints as truth in the database — agree, and strongly so. His &lt;code&gt;CommandSingleEntrant&lt;/code&gt; RuboCop cop makes the single-entry rule structural rather than disciplinary — and he&amp;rsquo;s honest about its reach: &amp;ldquo;Both catch the common mistakes&amp;rdquo;, with &lt;code&gt;class &amp;lt;&amp;lt; self&lt;/code&gt; patterns as a known blind spot.&lt;/p&gt;

&lt;p&gt;My point isn&amp;rsquo;t &amp;ldquo;your pattern is too weak&amp;rdquo;. It&amp;rsquo;s: don&amp;rsquo;t call it a &lt;em&gt;command&lt;/em&gt; and &lt;em&gt;event&lt;/em&gt; when it isn&amp;rsquo;t. Because the difference between a &lt;em&gt;procedure&lt;/em&gt; and a &lt;em&gt;command&lt;/em&gt;, and between &lt;em&gt;notification&lt;/em&gt; and a &lt;em&gt;domain event&lt;/em&gt;, isn&amp;rsquo;t pedantry — it&amp;rsquo;s the difference between &amp;ldquo;an invariant has an owner&amp;rdquo; and &amp;ldquo;an invariant is smeared, but nicely named&amp;rdquo;.&lt;/p&gt;

&lt;p&gt;I anticipate three counterarguments:&lt;/p&gt;

&lt;p&gt;&amp;ldquo;It&amp;rsquo;s a dispute about names.&amp;rdquo; Yes — and names are a contract that will reach 500 people. A bad mental model scales worse than bad code, because code gets refactored, but beliefs don&amp;rsquo;t.&lt;/p&gt;

&lt;p&gt;&amp;ldquo;ES is overkill.&amp;rdquo; Agreed, for most people. But a persistent record of a fact isn&amp;rsquo;t &lt;em&gt;event sourcing&lt;/em&gt; — it&amp;rsquo;s a single &lt;code&gt;INSERT&lt;/code&gt; in the same transaction as a state change, turning &amp;ldquo;effect can be lost&amp;rdquo; into &amp;ldquo;effect always recoverable&amp;rdquo;. You don&amp;rsquo;t skip this because you&amp;rsquo;re not doing event sourcing; it&amp;rsquo;s record path hygiene, not architecture.&lt;/p&gt;

&lt;p&gt;Steelman 37signals — whom Weaver honestly quotes — says that disciplined callbacks scale further than they&amp;rsquo;re given credit for. In a single, cohesive team: they&amp;rsquo;re not wrong. But the same caveat applies to his solution: &lt;em&gt;Transaction Script&lt;/em&gt; without an invariant owner also relies on discipline, which is generally absent with 500 people. The aggregate approach doesn&amp;rsquo;t ask for discipline — the structure enforces the rule.&lt;/p&gt;
&lt;h2 id="landing"&gt;Landing&lt;/h2&gt;
&lt;p&gt;Single-ingress is correct. But an ingress that doesn&amp;rsquo;t own the invariant isn&amp;rsquo;t a fix — it&amp;rsquo;s moving the same rule spread from a callback to a procedure. And calling that &lt;em&gt;procedure&lt;/em&gt; a &lt;em&gt;command&lt;/em&gt; and the &lt;em&gt;notification&lt;/em&gt; an &lt;em&gt;event&lt;/em&gt; installs a mental model for 500 engineers that doesn&amp;rsquo;t fit the code they have in their hands.&lt;/p&gt;

&lt;p&gt;The strongest version of his own argument isn&amp;rsquo;t the one he wrote — it&amp;rsquo;s the one he&amp;rsquo;s getting closer to: an &lt;em&gt;aggregate&lt;/em&gt; that monitors the &lt;em&gt;invariant&lt;/em&gt;, a true &lt;em&gt;domain event&lt;/em&gt;, and a fact persisted in the same &lt;em&gt;transaction&lt;/em&gt; as the state change. Weaver ends with a CQRS announcement &amp;ldquo;next time,&amp;rdquo; and it&amp;rsquo;s a good announcement, because single-ingress without an invariant owner is only halfway down the road he&amp;rsquo;s charted.&lt;/p&gt;

&lt;p&gt;I look forward to the continuation of the series — honestly, without irony.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-06-11:/res-mcp-server/</id>
    <title type="html">Let Your AI Assistant Explore the Event Store with res-mcp</title>
    <published>2026-06-11T10:00:00Z</published>
    <updated>2026-06-11T10:00:00Z</updated>
    <author>
      <name>Tomasz Patrzek</name>
      <uri>https://blog.arkency.com/authors/tomasz-patrzek/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/res-mcp-server/" type="text/html"/>
    <content type="html">&lt;h1 id="let_your_ai_assistant_explore_the_event_store_with__code_res_mcp__code_"&gt;Let Your AI Assistant Explore the Event Store with &lt;code&gt;res-mcp&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;When you&amp;rsquo;re debugging with an AI assistant, half the work is giving it enough context. You copy event payloads into the chat, paste stream contents, look up IDs, then repeat the process every time you need another piece of information.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ruby_event_store-mcp&lt;/code&gt; removes that step. It&amp;rsquo;s the companion to the &lt;a href="https://blog.arkency.com/res-cli"&gt;res CLI&lt;/a&gt;. Instead of you querying the event store from the terminal, your AI assistant does it for you through &lt;a href="https://modelcontextprotocol.io/"&gt;MCP tools&lt;/a&gt;. You ask questions in plain English, it reads the events itself.&lt;/p&gt;

&lt;p&gt;The difference from copying the events into the chat yourself is that the assistant can ask follow-up questions on its own. If it needs to inspect another stream, load an aggregate&amp;rsquo;s history, or trace a correlation, it simply calls another tool. You stay in the conversation instead of switching between your AI client and a terminal.&lt;/p&gt;

&lt;!-- more --&gt;
&lt;h2 id="setup"&gt;Setup&lt;/h2&gt;
&lt;p&gt;Add the gem and install:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"ruby_event_store-mcp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle install
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That installs the &lt;code&gt;res-mcp&lt;/code&gt; binary — but it doesn&amp;rsquo;t register anything with your AI client yet. The binary speaks MCP over &lt;strong&gt;stdio&lt;/strong&gt;, launched from your app&amp;rsquo;s root, where — exactly like the &lt;code&gt;res&lt;/code&gt; CLI — it loads &lt;code&gt;config/environment.rb&lt;/code&gt; and reads your app&amp;rsquo;s &lt;code&gt;Rails.configuration.event_store&lt;/code&gt;. There&amp;rsquo;s no HTTP endpoint to mount and nothing to deploy. Telling your client about it is a separate, one-time step — and every MCP client takes the same server definition, only the file it goes in changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; — drop a &lt;code&gt;.mcp.json&lt;/code&gt; in your project root:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"res"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"res-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(or run &lt;code&gt;claude mcp add res -- bundle exec res-mcp&lt;/code&gt;). Launched from the project directory, Claude Code runs the server there, so no &lt;code&gt;cwd&lt;/code&gt; is needed. On the next launch it asks you to trust the project&amp;rsquo;s MCP server — approve it, then run &lt;code&gt;/mcp&lt;/code&gt; to see &lt;code&gt;res&lt;/code&gt; connected with its nine tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; — the same block, but with an explicit &lt;code&gt;cwd&lt;/code&gt; pointing at your app&amp;rsquo;s root, in &lt;code&gt;claude_desktop_config.json&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"res"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"res-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/your/rails/app"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(macOS: &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;; Windows: &lt;code&gt;%APPDATA%\Claude\claude_desktop_config.json&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Other MCP clients&lt;/strong&gt; — Cursor, Windsurf, Cline and the rest take the same &lt;code&gt;mcpServers&lt;/code&gt; block in their own config file; VS Code&amp;rsquo;s built-in MCP uses a &lt;code&gt;servers&lt;/code&gt; key with &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;stdio&amp;quot;&lt;/code&gt; instead. The &lt;code&gt;bundle exec res-mcp&lt;/code&gt; command is the portable part.&lt;/p&gt;

&lt;p&gt;That&amp;rsquo;s the whole setup. No routes, no mounts, no credentials.&lt;/p&gt;
&lt;h2 id="ask_questions__not_commands"&gt;Ask questions, not commands&lt;/h2&gt;
&lt;p&gt;Once it&amp;rsquo;s connected you just talk to the assistant — &lt;strong&gt;no slash command, no skill&lt;/strong&gt;. You ask in plain English and it decides which tools to call (the first call asks your permission; allowlist the &lt;code&gt;res&lt;/code&gt; server to stop being asked).&lt;/p&gt;

&lt;p&gt;The server gives the assistant nine &lt;strong&gt;read-only&lt;/strong&gt; tools over your event store. You never call them by name — you ask a question, and the assistant picks the tools it needs. They cover three kinds of questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browse streams and events&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;stream_show&lt;/code&gt; — a stream&amp;rsquo;s event count, version, and first/last event&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stream_events&lt;/code&gt; — the events in a stream, filterable by type, time, or position&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_show&lt;/code&gt; — one event in full, data and metadata&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_streams&lt;/code&gt; — every stream a given event belongs to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Search and summarize&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;recent&lt;/code&gt; — the most recent events across the whole store (&amp;ldquo;what just happened?&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search&lt;/code&gt; — events anywhere, filtered by type, time range, or stream&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stats&lt;/code&gt; — total counts and the unique event types present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Follow a process&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;aggregate_history&lt;/code&gt; — the full event history of one aggregate instance (e.g. a single &lt;code&gt;Fulfillment::Order&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trace&lt;/code&gt; — the causation tree of everything sharing a correlation ID, so you can see where a multi-step flow stopped&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="examples"&gt;Examples&lt;/h2&gt;
&lt;p&gt;For example, instead of browsing the streams yourself, you can simply ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;What just happened? Show the 20 most recent events.&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Walk me through the history of Fulfillment::Order f47ac10b-58cc-4372-a567-0e02b2c3d479.&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Are there any OrderPlaced events from the last hour without a matching OrderConfirmed?&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Trace correlation 452fd6f0-e3a2-4716-bc8a-43bbcf2cae61 — where did the process stop?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant calls &lt;code&gt;recent&lt;/code&gt;, &lt;code&gt;aggregate_history&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, or &lt;code&gt;trace&lt;/code&gt; behind the scenes, reads the results, and reasons over them — no copy-pasting event payloads into the chat, no switching to a Rails console mid-thought.&lt;/p&gt;
&lt;h2 id="why_it__39_s_safe_to_point_at_any_app"&gt;Why it&amp;rsquo;s safe to point at any app&lt;/h2&gt;
&lt;p&gt;Every tool uses the public &lt;code&gt;event_store.*&lt;/code&gt; API — the same one your application uses through &lt;code&gt;Rails.configuration.event_store&lt;/code&gt;. There&amp;rsquo;s no direct SQL access, no ActiveRecord internals, and no adapter-specific code.&lt;/p&gt;

&lt;p&gt;The server is also intentionally read-only. Your AI assistant can inspect events, streams, and correlations, but it cannot append, link, or delete events. The worst it can do is answer your questions.&lt;/p&gt;
</content>
  </entry>
</feed>

