<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kirera paul murithi</title>
    <description>The latest articles on DEV Community by Kirera paul murithi (@paulmurithi).</description>
    <link>https://dev.to/paulmurithi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1242848%2Ffd1c82cd-f6d7-4a98-85df-e3983ce038c0.jpeg</url>
      <title>DEV Community: Kirera paul murithi</title>
      <link>https://dev.to/paulmurithi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paulmurithi"/>
    <language>en</language>
    <item>
      <title>The Gateway, the Registry and the Executor: Keeping Groq at the Edge of My Backend</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Sun, 11 Oct 2026 10:29:51 +0000</pubDate>
      <link>https://dev.to/paulmurithi/the-gateway-the-registry-and-the-executor-keeping-groq-at-the-edge-of-my-backend-18jp</link>
      <guid>https://dev.to/paulmurithi/the-gateway-the-registry-and-the-executor-keeping-groq-at-the-edge-of-my-backend-18jp</guid>
      <description>&lt;p&gt;&lt;em&gt;SokoFlow AI Build Log, Week 2 of 6: the LLMClient, the ToolExecutor, and one component that almost became a god object&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Week 2 goal said "wire three tools end-to-end."&lt;/p&gt;

&lt;p&gt;By the end of the week, all three tools executed against PostgreSQL and came back as structured results. I still wouldn't call it end-to-end, and the reason is the most useful thing I learned this week.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Week 2 built the provider boundary (&lt;code&gt;LLMClient&lt;/code&gt;) and the execution boundary (&lt;code&gt;ToolExecutor&lt;/code&gt;), then ran three tools through them: &lt;code&gt;get_sales_summary&lt;/code&gt;, &lt;code&gt;get_stock_level&lt;/code&gt; and &lt;code&gt;get_top_products&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LLMClient&lt;/code&gt; is a translator and gateway. It keeps Groq's concepts at the edge so the rest of SokoFlow speaks SokoFlow's language. It does &lt;strong&gt;not&lt;/strong&gt; know what a tool means.&lt;/li&gt;
&lt;li&gt;I got two things wrong first: I let &lt;code&gt;LLMClient&lt;/code&gt; become a "god" object, and I let &lt;code&gt;ToolExecutor&lt;/code&gt; own domain logic. A simple "Milk" query showed me why that's wrong.&lt;/li&gt;
&lt;li&gt;I verified the execution layer, not the full LLM round trip. I'm not going to claim a loop works when I only tested half of it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where we are
&lt;/h2&gt;

&lt;p&gt;SokoFlow is a headless WhatsApp ERP for Kenyan SMEs. There's no app and no UI to learn; for a shopkeeper, the conversation is the interface. Phase 3 adds one AI feature: natural-language shop analytics. A shopkeeper asks "which products are running low?" or "how does this week compare to last week?", and an LLM, using function calling against SokoFlow's existing services, figures out which query to run.&lt;/p&gt;

&lt;p&gt;Last week I designed seven read-only tools and benchmarked three models across two providers on the same 10 shopkeeper queries. Groq's &lt;code&gt;openai/gpt-oss-20b&lt;/code&gt; won. But right now those tools sit in isolation, and on their own they aren't worth much. This week's goal was to connect at least three of them.&lt;/p&gt;

&lt;p&gt;That takes three components: an &lt;code&gt;LLMClient&lt;/code&gt;, a &lt;code&gt;ToolRegistry&lt;/code&gt; and a &lt;code&gt;ToolExecutor&lt;/code&gt;. Each has its own task and its own boundary. So why do we need all three?&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: provider coupling
&lt;/h2&gt;

&lt;p&gt;The first thing I had to understand was the provider boundary. Last week I picked one provider for this phase, and my first instinct was the obvious one: call the Groq SDK directly wherever SokoFlow needs the model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SokoFlow component
        ↓
    Groq SDK
        ↓
       Groq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I quickly saw the flaws in that design. The biggest was &lt;strong&gt;provider coupling&lt;/strong&gt;: Groq's concepts would leak into the whole application. The second was &lt;strong&gt;cross-cutting duplication&lt;/strong&gt;: logging, latency tracking, retries, error mapping and metrics would get scattered everywhere.&lt;/p&gt;

&lt;p&gt;And switching providers isn't a hypothetical for me. Llama 3.3 70B on OpenRouter also scored 10/10 in last week's benchmark, so a swap is a realistic future decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLMClient: one gateway to the provider
&lt;/h2&gt;

&lt;p&gt;There had to be a better way, and that's where I learned the idea of an LLM client. What if there were exactly one gateway to the provider? The rest of the application talks to that client and doesn't care who's behind it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SokoFlow
    │
    ▼
LLMClient
    │
    ▼
Provider SDK / API
    │
    ▼
Groq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deeper purpose isn't just making providers replaceable. It's keeping provider-specific concerns at the edge of the system, so the rest of SokoFlow can reason in SokoFlow's own language. The &lt;code&gt;LLMClient&lt;/code&gt; plays two roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Translator:&lt;/strong&gt; SokoFlow input becomes a provider request, and the provider response becomes SokoFlow output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway:&lt;/strong&gt; it's the one controlled point through which SokoFlow talks to the external LLM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Groq exposes &lt;code&gt;response.choices[0].message.tool_calls...&lt;/code&gt;, nothing outside &lt;code&gt;LLMClient&lt;/code&gt; should ever need to know that structure exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "god object" I almost built
&lt;/h2&gt;

&lt;p&gt;I was treating &lt;code&gt;LLMClient&lt;/code&gt; as the silver bullet. It was the right answer, but I kept giving it more to do. At first I figured it would also handle tool semantics, tool existence checks, and the retry policy for semantically bad model responses. It was turning into a "god" object that knows far too much.&lt;/p&gt;

&lt;p&gt;So I defined three contracts for the provider boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input contract.&lt;/strong&gt; What SokoFlow sends to the LLM: the conversation messages, the available tool definitions, and the model configuration. The rule: SokoFlow expresses the request in a SokoFlow-owned representation, never a provider SDK request object. The client translates it into the provider's format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Output contract.&lt;/strong&gt; What SokoFlow actually needs back. The first time I saw a raw response, there was a lot in it (abbreviated here):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-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;"choices"&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="err"&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;"usage"&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="err"&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;"system_fingerprint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"created"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123456&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&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;Should the client hand back the entire Groq response object, or a &lt;strong&gt;small SokoFlow-owned result&lt;/strong&gt;? Returning the whole object would recreate the exact coupling I was avoiding, with Groq details spread through the app. So the client normalizes the response into a small result with only what the application needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error contract.&lt;/strong&gt; Groq's SDK exposes its own exceptions, like &lt;code&gt;groq.APIConnectionError&lt;/code&gt; and &lt;code&gt;groq.APITimeoutError&lt;/code&gt;. If application code catches those, error handling is coupled to Groq too. Instead, provider-specific failures are translated at the &lt;code&gt;LLMClient&lt;/code&gt; boundary into provider-agnostic application errors, wherever SokoFlow needs to react to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The responsibility split: who handles a bad tool call?
&lt;/h2&gt;

&lt;p&gt;Should &lt;code&gt;LLMClient&lt;/code&gt; validate tool names and arguments? To answer that, I ran a thought experiment. Suppose the provider returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tool = "delete_everything"
arguments = {"random invalid": true}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Who handles it? My answer: not the &lt;code&gt;LLMClient&lt;/code&gt;. As far as the client is concerned, it got a response from the provider. No infrastructure or provider error happened, so it did its job as gateway and translator. It shouldn't know what &lt;code&gt;get_sales_summary&lt;/code&gt; or &lt;code&gt;delete_everything&lt;/code&gt; &lt;em&gt;means&lt;/em&gt;, which periods are valid, or what the business rules are. So the design decision was to normalize the request and let downstream components deal with it.&lt;/p&gt;

&lt;p&gt;(One more piece I'll keep mentioning: the &lt;strong&gt;Orchestrator&lt;/strong&gt;. It will sit above these components, take the model's response, run the tool, and decide what happens next. It wasn't finished this week. More on that below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ToolRegistry exists
&lt;/h2&gt;

&lt;p&gt;Continuing the same thought experiment: the model asked for &lt;code&gt;delete_everything&lt;/code&gt;. Who detects that no such tool exists?&lt;/p&gt;

&lt;p&gt;From Week 1, the &lt;code&gt;ToolRegistry&lt;/code&gt; is the single source of truth for every tool SokoFlow exposes and the backend handler behind each one. So if any component should detect a missing tool, it's the registry. Its job is to answer one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What capabilities does SokoFlow expose to the model, and how does a proposed capability map into the backend?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But that left a gap. We now know the tool exists. What about running it?&lt;/p&gt;

&lt;h2&gt;
  
  
  The ToolExecutor: the execution boundary
&lt;/h2&gt;

&lt;p&gt;That's the third component. &lt;code&gt;LLMClient&lt;/code&gt; talks to the provider, the registry confirms a supported tool was requested, and the &lt;code&gt;ToolExecutor&lt;/code&gt; owns the execution boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are the arguments structurally valid?&lt;/li&gt;
&lt;li&gt;Convert them into the typed input the handler expects.&lt;/li&gt;
&lt;li&gt;Call the handler.&lt;/li&gt;
&lt;li&gt;Normalize the outcome.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I got wrong first:&lt;/strong&gt; I started by designing the executor to handle all domain logic. Then I considered this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get_stock_level(product_name="Milk")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose "Milk" matches three products: Milk 500ml, Milk 1L and Milk 2L. The model supplied a perfectly valid string according to the schema. But the &lt;em&gt;domain operation&lt;/em&gt; knows the product is ambiguous and needs clarification. A &lt;code&gt;ToolExecutor&lt;/code&gt; containing product-resolution rules would be doing the domain's job.&lt;/p&gt;

&lt;p&gt;So the split became clear: &lt;strong&gt;schema and contract validation belong to the executor. Business and domain validation stay with the existing domain services.&lt;/strong&gt; This revises what I described last week, when I put domain validation in the same gate as schema validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The final failure taxonomy
&lt;/h3&gt;

&lt;p&gt;With that boundary, there are three distinct failure scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unknown tool&lt;/strong&gt;, like &lt;code&gt;tool_name = "delete_inventory"&lt;/code&gt;. The registry lookup fails and the executor rejects it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structurally invalid arguments&lt;/strong&gt;, like &lt;code&gt;get_top_products(limit=10, dimension="profit")&lt;/code&gt;. Schema validation fails and the executor rejects it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structurally valid, domain-invalid request&lt;/strong&gt;, like &lt;code&gt;get_stock_level(product_name="Milk")&lt;/code&gt;. The schema says it's fine, but the backend can't uniquely resolve the product. That's &lt;em&gt;not&lt;/em&gt; a schema failure. The executor validated the request and reached the handler, and the domain operation answers with &lt;code&gt;AmbiguousProduct&lt;/code&gt;. The Orchestrator then decides whether to ask the shopkeeper to clarify.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The result envelope
&lt;/h3&gt;

&lt;p&gt;One more decision. Once the executor finds the tool, validates the arguments and calls the handler, what does it return?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The raw handler result&lt;/strong&gt;, a Pydantic model such as a revenue summary (for example, &lt;code&gt;{"total_revenue": 4250, "transaction_count": 17}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An executor-owned result&lt;/strong&gt; that wraps the handler's output:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_sales_summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&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="err"&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;I chose the second. It separates &lt;em&gt;execution metadata&lt;/em&gt; (did this run succeed?) from &lt;em&gt;domain data&lt;/em&gt; (what the handler returned), so the Orchestrator doesn't need to understand every domain result just to know whether execution worked.&lt;/p&gt;

&lt;p&gt;The key limit: the executor owns the envelope, but it must not reshape the domain data inside it. Otherwise it grows a universal "mega-schema" and gets coupled to every tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ToolExecutionResult
├── revenue
├── products
├── stock
├── transaction_count
├── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the envelope looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ToolExecutionResult
├── execution metadata
└── data → domain result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The first three tools
&lt;/h2&gt;

&lt;p&gt;With the boundaries set, I wired three analytics tools through the deterministic backend path: &lt;code&gt;get_sales_summary&lt;/code&gt;, &lt;code&gt;get_stock_level&lt;/code&gt; and &lt;code&gt;get_top_products&lt;/code&gt;. Each one executes through the existing &lt;code&gt;ToolRegistry&lt;/code&gt; and &lt;code&gt;ToolExecutor&lt;/code&gt;, reuses SokoFlow's services, reaches PostgreSQL, and returns a structured result. I covered the tools themselves in the &lt;a href="https://dev.to/paulmurithi/the-llm-interprets-the-backend-owns-the-truth-week-1-of-adding-ai-to-a-whatsapp-erp-162f"&gt;Week 1 blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I didn't wire in the full WhatsApp conversation (the next section explains why). Instead I leaned on the principle that guided the MVP: tests. Each tool got multiple unit tests, and there's at least one integration test touching all the related components.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What the contract covers&lt;/th&gt;
&lt;th&gt;Verified by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_sales_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An explicit reporting period, backend-owned date calculations, shop-timezone-aware relative periods, half-open boundaries, valid zero-sales results, and &lt;code&gt;total_revenue&lt;/code&gt; and &lt;code&gt;transaction_count&lt;/code&gt; in the output&lt;/td&gt;
&lt;td&gt;Unit tests (mocks and patches) for validation and execution; integration test reaching PostgreSQL through the existing repository; result wrapped in &lt;code&gt;ToolExecutionResult&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_stock_level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backend-owned product resolution; existing stock and product services; ambiguous matches handled by the domain&lt;/td&gt;
&lt;td&gt;Unit tests; integration test reaching PostgreSQL; result wrapped in &lt;code&gt;ToolExecutionResult&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_top_products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Units and revenue rankings, an optional ranking dimension, both rankings when none is given, and a limit applied to both. Structural validation rejects invalid dimensions like &lt;code&gt;profit&lt;/code&gt; and invalid limits like zero before the handler runs&lt;/td&gt;
&lt;td&gt;Unit tests; integration test reaching PostgreSQL; result wrapped in &lt;code&gt;ToolExecutionResult&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The part where I changed the plan
&lt;/h2&gt;

&lt;p&gt;The Week 2 objective was to wire these three tools end-to-end &lt;em&gt;outside&lt;/em&gt; the WhatsApp flow, using a standalone script or REPL harness that also measured p95 latency.&lt;/p&gt;

&lt;p&gt;Partway through, I hit a distinction I hadn't thought about: &lt;strong&gt;testing tool execution&lt;/strong&gt; is not the same as &lt;strong&gt;testing the complete LLM-to-tool interaction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The three tools had registered handlers connected to the service and repository layers, with unit tests and PostgreSQL-backed integration tests. But exercising the full LLM-driven path is harder. A genuine flow needs the model's tool call to pass through the Orchestrator, which coordinates model responses, tool execution and what happens next. At this stage, the Orchestrator and its conversation-level integration weren't complete.&lt;/p&gt;

&lt;p&gt;I considered making a real model call with temporary workarounds. But that would have meant writing a half-baked orchestration layer ahead of schedule, and it would have made it harder to tell a tool-execution bug from an orchestration bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I made a deliberate scope decision.&lt;/strong&gt; I kept Week 2 on the provider boundary, the registry, the executor and the three tool handlers. The standalone harness feeds normalized, model-shaped tool calls straight into the &lt;code&gt;ToolExecutor&lt;/code&gt;, so I can test successful execution and failure cases without building the WhatsApp path early.&lt;/p&gt;

&lt;p&gt;There's an important qualification. That harness verifies the backend execution path. It does &lt;strong&gt;not&lt;/strong&gt; prove that a real LLM can pick a tool, have the Orchestrator run it, and get the result back in a full round trip with latency measured and recorded. That work moves to Week 3, where analytics connects to the conversation flow and gets verified through the WhatsApp simulator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; an end-to-end test is only meaningful when its boundaries are clear. I didn't want to claim the whole function-calling loop worked when I'd only verified its execution layer. I'd rather move the unfinished integration to the week where it belongs than force it early and trust a result I can't interpret.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I wrote the goal "end-to-end" before checking what an end-to-end run actually depends on. It depends on the Orchestrator, and that didn't exist yet. Next time I would list the dependencies of an end-to-end goal before I commit to the word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Week 2 landed
&lt;/h2&gt;

&lt;p&gt;Here's how responsibility is divided now:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLMClient&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Provider interaction and the normalized tool call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ToolRegistry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exposed tools, schemas and handler mappings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ToolExecutor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lookup, structural validation, safe invocation, and the execution result envelope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Domain Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business rules and the actual operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orchestrator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What happens next, including retries and recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And against the Week 2 target of three tools wired end-to-end from a harness, with p95 latency recorded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Done:&lt;/strong&gt; the provider boundary, the executor and three tools running through the execution layer, tested against PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carried to Week 3:&lt;/strong&gt; a real LLM tool call going through the Orchestrator and executor in a full round trip, with p95 latency measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Week 3 is the fun part: the WhatsApp and conversation integration. That means adding a new &lt;code&gt;ANALYTICS_QUERY&lt;/code&gt; intent to the existing resolver, so complex free-text messages are routed to the LLM. It also means handling session context, so a follow-up like "and yesterday?" can reuse what came before.&lt;/p&gt;

&lt;p&gt;Stay locked in as SokoFlow slowly gains intelligence.&lt;/p&gt;

&lt;p&gt;Check out the source code on &lt;a href="https://github.com/paul-murithi/SokoFlow" rel="noopener noreferrer"&gt;Github&lt;/a&gt;.&lt;br&gt;
Read more from the 4-Month &lt;a href="https://dev.to/paulmurithi/series/41476"&gt;MVP Blogs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>llm</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>The LLM Interprets, the Backend Owns the Truth: Week 1 of Adding AI to a WhatsApp ERP</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Mon, 05 Oct 2026 20:53:49 +0000</pubDate>
      <link>https://dev.to/paulmurithi/the-llm-interprets-the-backend-owns-the-truth-week-1-of-adding-ai-to-a-whatsapp-erp-162f</link>
      <guid>https://dev.to/paulmurithi/the-llm-interprets-the-backend-owns-the-truth-week-1-of-adding-ai-to-a-whatsapp-erp-162f</guid>
      <description>&lt;h2&gt;
  
  
  I Spent Week 1 of Adding AI to My ERP Deciding What the AI Is Not Allowed to Do
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;SokoFlow AI Build Log, Week 1 of 6: tool boundaries, a timezone detour, and a bug with two correct answers&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two correct answers. One question: &lt;em&gt;"Compare this month with the previous period."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My code picked one of them, silently, and never told me it had a choice.&lt;/p&gt;

&lt;p&gt;That bug is why Week 1 of adding AI to SokoFlow had almost nothing to do with AI.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Week 1 wasn't about wiring an LLM into SokoFlow. It was about deciding what the LLM gets to own, and what it doesn't.&lt;/li&gt;
&lt;li&gt;The rule I locked in: &lt;strong&gt;the LLM interprets language, the backend owns the truth.&lt;/strong&gt; The model picks a tool and proposes arguments. It never executes anything, never supplies &lt;code&gt;shop_id&lt;/code&gt;, and never decides what "today" means.&lt;/li&gt;
&lt;li&gt;"Previous period" has two valid meanings. My code was choosing one without telling me. Some bugs aren't implementation failures, they're failures to define what the thing means.&lt;/li&gt;
&lt;li&gt;I treat LLM output like an untrusted client request: validate the shape, then the business rules, then allow one bounded retry for cheap model slips.&lt;/li&gt;
&lt;li&gt;Provider: I benchmarked three models across Groq and OpenRouter and chose Groq's &lt;code&gt;openai/gpt-oss-20b&lt;/code&gt;: 10/10 queries routed correctly, 1309 ms average latency, about $0.00005 per call.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where SokoFlow was
&lt;/h2&gt;

&lt;p&gt;Welcome to the new series. SokoFlow is a headless WhatsApp ERP and conversational commerce engine for Kenyan SMEs. There's no app to download and no UI to learn. For a shopkeeper, the conversation &lt;em&gt;is&lt;/em&gt; the interface. The 16-week MVP is shipped, and I wrote about it along the way. You can read the series &lt;a href="https://dev.to/paulmurithi/series/41476"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The MVP is deterministic and rule-based on purpose, and that isn't changing. But it only understands specific commands that trigger specific flows. For an ERP aimed at small shops, that's enough. I've also always wanted to explore AI systems, and extending something I already understand felt like the best way to do it.&lt;/p&gt;

&lt;p&gt;So Phase 3 adds exactly one AI feature: &lt;strong&gt;natural-language shop analytics.&lt;/strong&gt; A shopkeeper asks "which products are running low?" or "how does this week compare to last week?", and an LLM, using function calling against SokoFlow's existing internal services, figures out which query to run.&lt;/p&gt;

&lt;p&gt;The guardrails I set up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No vector DB, no RAG, no fine-tuning.&lt;/li&gt;
&lt;li&gt;No new data store, no dashboard, no multi-shop analytics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No writes through the LLM.&lt;/strong&gt; "Record a sale" stays FSM-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Week 1 was the &lt;strong&gt;Tool Surface &amp;amp; Provider Decision&lt;/strong&gt;: finalize 5–8 tool schemas, benchmark LLM providers on function-calling reliability, and decide fallback policy and guardrails. The goal was not to connect an LLM to SokoFlow. It was to understand where an LLM &lt;em&gt;should&lt;/em&gt; fit without handing it responsibilities that belong to the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: two messages that look alike but aren't
&lt;/h2&gt;

&lt;p&gt;Imagine a shopkeeper sends one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Message B:&lt;/strong&gt; &lt;code&gt;sell 2 bread&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The existing system handles this well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Intent:   RECORD_SALE
Product:  bread
Quantity: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's constrained. The intent resolver recognizes the pattern and the FSM walks the user through whatever's missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Message A:&lt;/strong&gt; &lt;code&gt;How much did I sell today?&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;There's no fixed conversational flow to enter. Something has to infer at least two things: &lt;em&gt;what operation does the user want?&lt;/em&gt; (a sales summary) and &lt;em&gt;what time period?&lt;/em&gt; (today).&lt;/p&gt;

&lt;p&gt;Here's the key move. &lt;strong&gt;I'm not asking the LLM to answer the question. I'm asking it to interpret the question and pick an operation that can.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User's natural language
        ↓
"What does this person want?"
        ↓
"Sales summary for today"
        ↓
Which SokoFlow operation can provide that?
        ↓
get_sales_summary(...)
        ↓
Actual database/service logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;strong&gt;tool calling&lt;/strong&gt;: instead of replying with prose, the model replies with a structured request, usually JSON, naming a tool and its arguments. It doesn't run anything. Your code does.&lt;/p&gt;

&lt;p&gt;When the model outputs &lt;code&gt;get_sales_summary(period="today")&lt;/code&gt;, that isn't an answer. It's a &lt;em&gt;request&lt;/em&gt; to SokoFlow to perform an operation. The LLM sits at the interpretation boundary between messy human language and SokoFlow's structured world, and it never becomes the source of truth.&lt;/p&gt;

&lt;p&gt;One difference from the textbook loop: in the generic version, the tool result goes back to the model so it can write the final reply. I'm deliberately not doing that. SokoFlow's &lt;code&gt;ResponseFormatter&lt;/code&gt; turns the structured result into a reply using templates and i18n. My reasoning: a shopkeeper's revenue figure shouldn't pass through a text generator that might rephrase a number. Predictable beats eloquent.&lt;/p&gt;

&lt;p&gt;The architecture barely changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WhatsApp → Webhook → FSM / Intent Resolver → ANALYTICS_QUERY
   → LLM → tool selection + structured arguments
   → validation / executor → existing SokoFlow services
   → PostgreSQL / Redis → structured result
   → Response Formatter → WhatsApp reply → Interaction Logger
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent resolver flags free text as &lt;code&gt;ANALYTICS_QUERY&lt;/code&gt; only when it doesn't match an existing structured intent. And every LLM decision gets logged before it executes, so failures can be diagnosed afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hallucination, and why the boundary matters
&lt;/h2&gt;

&lt;p&gt;An LLM isn't a database of answers. It learns statistical relationships, so it's good at recognizing that "How much did I sell…" lives near concepts like sales, amounts and time periods. That's why it can handle many phrasings of one question.&lt;/p&gt;

&lt;p&gt;It's also why it can invent things. Suppose a shopkeeper asks "How much did I sell today?" and the model replies with &lt;code&gt;get_monthly_profit&lt;/code&gt;. That tool doesn't exist (this is a made-up example), but the model would have invented a capability.&lt;/p&gt;

&lt;p&gt;The model doesn't know how to operate SokoFlow. It only knows the operations we show it: names, descriptions, required arguments. So every rule below comes from one stance: &lt;strong&gt;the model's output is a suggestion, never an instruction.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven tools, and the decisions hiding inside them
&lt;/h2&gt;

&lt;p&gt;I kept the surface small and read-only. Seven tools, each with a strict typed schema and bounded output.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;The decision that mattered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_sales_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shop_id&lt;/code&gt; is injected by the backend, never supplied by the LLM. Zero sales is a valid result, not an error.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_top_products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional ranking dimension (units, revenue, or both when the question is broad). A &lt;code&gt;limit&lt;/code&gt; defaults to 10 so WhatsApp doesn't get 500 rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_stock_level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Users say "sugar", not &lt;code&gt;product_id=42&lt;/code&gt;. A product-resolution step must turn names into trusted IDs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_low_stock_items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defaults to each product's own &lt;code&gt;low_stock_threshold&lt;/code&gt;. Explicit thresholds like "fewer than 3 units" don't exist yet in this phase.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_product_price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same name-to-ID problem as stock. Current price comes from &lt;code&gt;Product.price&lt;/code&gt;, not the historical &lt;code&gt;Sale.unit_price&lt;/code&gt; snapshot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_slow_moving_items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Products whose total units sold over the period are at or below &lt;code&gt;max_sales_count&lt;/code&gt;, which defaults to 2 unless overridden.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_sales_trend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The LLM interprets "this week vs last week"; the backend computes the exact boundaries. The metric (revenue, units, transaction count, or all) depends on the question.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these decisions are worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM never supplies &lt;code&gt;shop_id&lt;/code&gt;.&lt;/strong&gt; For a shopkeeper in Nairobi, the worst-case bug is seeing someone else's sales numbers. Scope comes from the authenticated session, injected server-side. The model can't ask for another shop because the argument doesn't exist in its schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Sugar" must become a trusted ID before any tool runs.&lt;/strong&gt; I already had fuzzy matching with &lt;code&gt;pg_trgm&lt;/code&gt; and candidate handling from the MVP, so I reused it. And the system doesn't guess: if several products match, or the best match scores low, it asks the shopkeeper to clarify. A missing product is a domain error, and I created a custom error for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM interprets the &lt;em&gt;meaning&lt;/em&gt; of "today", but it doesn't decide what date it is.&lt;/strong&gt; That belongs to the application, using the shop's timezone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explicit over ambiguous: the schema that argued with itself
&lt;/h2&gt;

&lt;p&gt;Consider "How much did I sell this week?" The LLM can tell the user means a weekly period. But should it calculate timestamps? I first sketched a schema like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;relative_period: day | week | month | year
exact_start_date: string | optional
exact_end_date: string | optional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the schema allows contradictory input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;relative_period = "week"
start_date = "2026-09-01"
end_date = "2026-09-30"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Who wins? Nobody can say, because those fields aren't different parameters. They're two ways of expressing one concept: &lt;em&gt;the requested reporting period.&lt;/em&gt; So the schema should force the model to answer one question first: &lt;strong&gt;am I expressing this period relatively, or as an explicit range?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;period_type
├── relative
│   └── relative_period: day | week | month | year
│
└── date_range
    ├── start_date
    └── end_date
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives each layer a clean job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM:&lt;/strong&gt; chooses the strategy and supplies the semantic values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validation:&lt;/strong&gt; ensures the chosen strategy has the required fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; derives actual boundaries using the shop timezone, calendar rules, &lt;code&gt;ZoneInfo&lt;/code&gt; and half-open intervals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; receives deterministic boundaries and just queries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;period_type&lt;/code&gt; is the discriminator, and the application enforces it with explicit checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;PeriodType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RELATIVE&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relative_period&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relative_period is required when period_type is &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;relative&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_range&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_range must be null when period_type is &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;relative&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;PeriodType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DATE_RANGE&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_range&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_range is required when period_type is &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date_range&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relative_period&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relative_period must be null when period_type is &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date_range&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A small detour: what does "today" even mean?
&lt;/h2&gt;

&lt;p&gt;Time keeps coming back in this project, and in backend work in general. "Show me today's sales" sounds trivial. But a backend doesn't live inside the user's wall clock. It deals with instants on a global timeline. Nairobi is UTC+3, so a sale rung up at 1 a.m. on the 5th is still the 4th in UTC. A shop owner closing late and a server clock can disagree about which day that sale belongs to.&lt;/p&gt;

&lt;p&gt;So the shop's timezone defines "today". The backend converts the reference instant into that timezone, derives the calendar date, and turns relative periods into &lt;strong&gt;calendar boundaries&lt;/strong&gt;, not durations. "This month" isn't "the last 30 days". It's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[start of this month, start of next month)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;strong&gt;half-open interval&lt;/strong&gt;: the start is included, the end isn't. It beats inventing an endpoint like &lt;code&gt;23:59:59.999999&lt;/code&gt;, because the next period's start is exactly this one's end, with no gaps and no overlap.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;ZoneInfo&lt;/code&gt;, &lt;code&gt;astimezone()&lt;/code&gt;, &lt;code&gt;datetime.combine()&lt;/code&gt; and &lt;code&gt;timedelta&lt;/code&gt;, but the APIs weren't the lesson. The lesson was the model: &lt;strong&gt;an instant, a timezone, a local calendar and boundaries are four different concepts.&lt;/strong&gt; In SokoFlow, the week starts on Monday, and "today" is always evaluated in the shop's timezone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug with two right answers
&lt;/h2&gt;

&lt;p&gt;Back to the hook. A user asks: &lt;em&gt;"Compare this month with the previous period."&lt;/em&gt; What does "previous period" mean?&lt;/p&gt;

&lt;p&gt;I caught this while manually reviewing one of the reports. The previous month's range started on August 2 instead of August 1.&lt;/p&gt;

&lt;p&gt;Here's how &lt;code&gt;get_sales_trend&lt;/code&gt; worked when I found it. If the caller gives an explicit comparison period, the handler resolves it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comparison_period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prev_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_period_boundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comparison_period&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If not, it derived one automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prev_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_previous_period_boundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that helper did it by subtracting the current period's length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;curr_end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;curr_start&lt;/span&gt;
&lt;span class="n"&gt;prev_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;curr_start&lt;/span&gt;
&lt;span class="n"&gt;prev_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prev_end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For September, that gives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current:   Sep 1 ──────── Oct 1   (30 days)
Previous:  Aug 2 ──────── Sep 1   (30 days)   ← equal duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But a shopkeeper asking about "last month" means all of August:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current:   Sep 1 ──────── Oct 1   (30 days)
Previous:  Aug 1 ──────── Sep 1   (31 days)   ← previous calendar period
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are mathematically valid. Only the &lt;em&gt;domain&lt;/em&gt; tells you which is correct. SokoFlow already thinks in calendar units (day, week, month, year), and months have different lengths (30, 31, 28 or 29 days), so "same number of days" isn't a reliable meaning of "equivalent period."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I chose:&lt;/strong&gt; by default, "previous period" means the &lt;strong&gt;previous equivalent calendar period.&lt;/strong&gt; The duration-based version isn't wrong, it's a &lt;em&gt;different&lt;/em&gt; question: "the last 30 days vs the 30 days before that." That should be asked for explicitly, not inherited by accident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Previous calendar period → "previous month", "previous week"
Previous duration window → "the 30 days immediately before this 30-day window"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An explicitly provided &lt;code&gt;comparison_period&lt;/code&gt; still resolves through &lt;code&gt;resolve_period_boundaries()&lt;/code&gt;. I went with the calendar option and recorded the decision in an ADR (architecture decision record), so the meaning is written down instead of living in one helper function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; some backend bugs aren't failures of implementation. They're failures to define what the implementation is supposed to mean. And it matters more with an LLM in front, because the model will happily translate a vague phrase into a vague argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never trust the client, even when the client is an LLM
&lt;/h2&gt;

&lt;p&gt;When the model produces a tool call, is that a request or a response? Both, depending on where you stand. In HTTP terms I sent the user's message and the model responded. In application terms it's a &lt;em&gt;request&lt;/em&gt; that my backend executes. And how do we treat requests? We don't blindly trust them. That was my turning point.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is passing the model's arguments straight to the handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's dangerous. The model can hallucinate arguments, add parameters that don't exist, or omit required ones, and any of those can crash application code. So the arguments go through a gate, in two stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM → raw_args → VALIDATION (Pydantic schema)
                    ├── ❌ invalid → stop
                    └── ✅ valid → handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1, schema validation:&lt;/strong&gt; does this data have the right shape and types? A missing required date, or a date that isn't a date, fails here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2, domain validation:&lt;/strong&gt; is it valid but nonsensical? A start date after the end date has the right types and still makes no business sense.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JSON Schema the model sees is generated from the same Pydantic models that validate its output, so there's one definition instead of two that can drift apart. Unexpected extra arguments aren't silently stripped. They cause a validation error and the call is rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure cases I designed for
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Missing tool:&lt;/strong&gt; the model emits a name like &lt;code&gt;get_sales_summery&lt;/code&gt;. Caught at registry lookup. (An illustrative example, not one I've observed yet.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed or missing arguments:&lt;/strong&gt; caught by stage 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain validation failure:&lt;/strong&gt; caught by stage 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product ambiguity:&lt;/strong&gt; "How much is milk?" matches several products. We don't guess, we ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure failure:&lt;/strong&gt; the database or Redis is down. The shopkeeper did nothing wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Who is at fault?
&lt;/h3&gt;

&lt;p&gt;Not all errors are the same, and the awkward part is that a missing tool isn't the &lt;em&gt;user's&lt;/em&gt; fault. Returning a generic "invalid request" would be bogus. The user's contract with SokoFlow is "I ask a question in natural language." The LLM's contract with my tool layer is "produce a valid tool call from the tools exposed." An invented tool name is a &lt;strong&gt;model failure&lt;/strong&gt;, not a user failure.&lt;/p&gt;

&lt;p&gt;That left two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retry once with the LLM&lt;/strong&gt;, with a constrained message: "Invalid tool name. Select one of the available tools."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail the AI operation&lt;/strong&gt; with a generic fallback like "I couldn't process that request right now," with no raw model error, no stack trace, and no internals exposed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I chose option 1. "How much did I make this week?" is a perfectly valid question, and failing it over a one-character generation slip punishes the shopkeeper for the model's typo. I worried this would balloon into a retry system. It doesn't need to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt = 1
LLM → tool call
      ↓
ToolRegistry
      ↓
ToolNotFound
      ↓
if attempt == 1:
    retry once
else:
    fail + log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle I locked in: &lt;strong&gt;recover from cheap, model-local errors when recovery is deterministic and bounded. Don't build elaborate machinery for failures that belong to the application or infrastructure.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry: deliberately boring
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ToolRegistry&lt;/code&gt; is the single source of truth. It owns the schemas the model sees, the routing map from tool name to the code that implements it, and the validation definitions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_sales_summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Calculates total revenue and transaction count for a shop over a requested &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reporting period.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;input_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GetSalesSummaryInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GetSalesSummaryOutput&lt;/span&gt;&lt;span class="p"&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;handle_get_sales_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't run queries. A string like &lt;code&gt;"get_sales_summary"&lt;/code&gt; maps to one handler, which calls the service method that does the work (here, &lt;code&gt;SalesService.fetch_sales()&lt;/code&gt;). Anything not in the map doesn't exist as far as the executor is concerned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider decision
&lt;/h2&gt;

&lt;p&gt;I wanted to stay on free or cheap models, like with the MVP. My early runs were noisy (OpenRouter returned 404s), so I tightened the benchmark script: it runs the same 10 sample shopkeeper queries against each model and scores function-calling reliability, latency and cost.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider / model&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Generated calls&lt;/th&gt;
&lt;th&gt;2xx&lt;/th&gt;
&lt;th&gt;Routed (tool + schema)&lt;/th&gt;
&lt;th&gt;Avg latency&lt;/th&gt;
&lt;th&gt;Avg cost / call&lt;/th&gt;
&lt;th&gt;Statuses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Groq (&lt;code&gt;openai/gpt-oss-20b&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;100% (10/10)&lt;/td&gt;
&lt;td&gt;1309 ms&lt;/td&gt;
&lt;td&gt;$0.00005&lt;/td&gt;
&lt;td&gt;200: 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter Free (&lt;code&gt;openrouter/free&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;20% (2/10)&lt;/td&gt;
&lt;td&gt;4590 ms&lt;/td&gt;
&lt;td&gt;$0.0000&lt;/td&gt;
&lt;td&gt;200: 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter (&lt;code&gt;meta-llama/llama-3.3-70b-instruct&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;100% (10/10)&lt;/td&gt;
&lt;td&gt;1905 ms&lt;/td&gt;
&lt;td&gt;$0.00042&lt;/td&gt;
&lt;td&gt;200: 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A query only counted as correctly routed if the model picked the exact expected tool &lt;strong&gt;and&lt;/strong&gt; its arguments passed my strict local Pydantic validation (schema and domain checks).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision: Groq with &lt;code&gt;openai/gpt-oss-20b&lt;/code&gt;.&lt;/strong&gt; It tied with Llama 3.3 70B at 10/10, so latency and cost broke the tie: 1309 ms vs 1905 ms, and about 8x cheaper per call. That clears the Week 1 bar of 8/10 correctly routed.&lt;/p&gt;

&lt;p&gt;One honest caveat: 10 queries is a small sample, and the two top models tied. The real test comes in Week 5, with a 25–40 query eval set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I found the "previous period" bug by eyeballing a report, not because something told me the definition was missing. Next time, the meaning of a business phrase gets written down before the first resolver is written, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Week 1 was less about writing code and more about deciding what the AI layer should be responsible for. The architecture has a shape, the boundaries are clearer, and I have something concrete to build against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1 KPIs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finalized and froze the schemas for all 7 read-only tools (sales summary, top products, stock level, low stock, price, sales trend, slow moving).&lt;/li&gt;
&lt;li&gt;Built a benchmark script with 10 real shopkeeper-style queries.&lt;/li&gt;
&lt;li&gt;Benchmarked 3 models across 2 providers on function-calling accuracy, latency and cost per call. The chosen model routed 10/10 queries correctly (target: 8/10).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next week the question changes from &lt;em&gt;"what should this system look like?"&lt;/em&gt; to &lt;em&gt;"can I actually make it work?"&lt;/em&gt; The plan is the core function-calling loop: &lt;code&gt;LLMClient&lt;/code&gt;, &lt;code&gt;ToolExecutor&lt;/code&gt;, and three tools (sales summary, stock level, top products) wired end-to-end from a manual harness, with p95 latency measured.&lt;/p&gt;

&lt;p&gt;The LLM interprets language. The backend owns the truth. See you next week.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>backend</category>
      <category>llm</category>
    </item>
    <item>
      <title>Shipping the Capstone: PDF Reports, Chaos Testing, and the Architecture of a System That Fails Gracefully</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:23:13 +0000</pubDate>
      <link>https://dev.to/paulmurithi/shipping-the-capstone-pdf-reports-chaos-testing-and-the-architecture-of-a-system-that-fails-3e08</link>
      <guid>https://dev.to/paulmurithi/shipping-the-capstone-pdf-reports-chaos-testing-and-the-architecture-of-a-system-that-fails-3e08</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log , Month 4 of 4
&lt;/h3&gt;




&lt;p&gt;Sixteen weeks ago, I opened an empty directory, ran &lt;code&gt;git init&lt;/code&gt;, and started building something I wasn't entirely sure I could finish.&lt;/p&gt;

&lt;p&gt;This is the final build log for &lt;strong&gt;SokoFlow&lt;/strong&gt; a conversational ERP for small Kenyan shopkeepers that lets them manage inventory and record sales entirely through WhatsApp chat. No app, no training, just natural language.&lt;/p&gt;

&lt;p&gt;If you've been following along: &lt;a href="https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f"&gt;Month 1&lt;/a&gt; was the business core, built with TDD. &lt;a href="https://dev.to/paulmurithi/thinking-like-a-platform-engineer-celery-docker-cicd-and-getting-sokoflow-into-the-cloud-109l"&gt;Month 2&lt;/a&gt; was infrastructure: Docker, CI/CD, and a live staging deployment. &lt;a href="https://dev.to/paulmurithi/teaching-sokoflow-to-remember-building-a-conversational-engine-with-fsms-redis-and-whatsapp-56mp"&gt;Month 3&lt;/a&gt; was the conversational engine: FSMs, Redis sessions, fuzzy matching, and a secured webhook.&lt;/p&gt;

&lt;p&gt;Month 4 was the capstone: &lt;strong&gt;async report delivery, a chaos testing suite, internationalization, and final integration.&lt;/strong&gt; Everything built over the previous 12 weeks came down to these four weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goals for Month 4
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Report PDF generated within 5 seconds and delivered as a document message via the mock sender.&lt;/li&gt;
&lt;li&gt;The system handles all chaos scenarios without crashing or corrupting state.&lt;/li&gt;
&lt;li&gt;A shop registered with &lt;code&gt;locale='sw'&lt;/code&gt; receives Swahili responses.&lt;/li&gt;
&lt;li&gt;Repository clone-to-running in under 5 minutes following the README.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Week 13  Async Report Delivery: ReportLab, PDF Bytes, and the report_tasks Queue
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two Worlds, One Translator
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f"&gt;Month 1, Week 4&lt;/a&gt;, I built the sales recording, aggregation, and report data generation. Month 4 built the final layer on top: turning that data into something a human can actually use.&lt;/p&gt;

&lt;p&gt;Imagine a shopkeeper asking SokoFlow for an end-of-day summary. The system can calculate this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Today's Sales:     KSh 45,000
Number of Sales:   37
Top Product:       Blue T-Shirt
Profit:            KSh 12,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But data is not documentation. The shopkeeper might want to download it, print it, email it to their accountant, or archive it for the month. A Python dictionary serves none of those needs.&lt;/p&gt;

&lt;p&gt;This is where a PDF comes in; and the PDF service's job is conceptually simple: &lt;strong&gt;translate between the computer's world and the human's world.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhm5ff1mezixtg37x1x0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhm5ff1mezixtg37x1x0.png" alt="Data flow diagram: database aggregation → DailySummaryResponse (Pydantic schema) → ReportLab → PDF document" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Computer World&lt;/th&gt;
&lt;th&gt;Human World&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"sales": 45000, "orders": 37, "profit": 12000}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SOKOFLOW DAILY REPORT , Sales: KSh 45,000, Orders: 37, Profit: KSh 12,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ReportLab
&lt;/h3&gt;

&lt;p&gt;For PDF generation, the options range from manual word processors to print-to-PDF workarounds; both of which have no place in an automated pipeline. &lt;strong&gt;ReportLab&lt;/strong&gt; is a Python library built precisely for this: generating complex, data-driven documents programmatically from any data source.&lt;/p&gt;

&lt;p&gt;Since SokoFlow is already a Python system, the integration is natural. ReportLab lets us express document structure directly in code , no external processes, no format conversions, no third-party API round trips.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Does the PDF Live? A Three-Way Architecture Decision
&lt;/h3&gt;

&lt;p&gt;Once ReportLab builds the PDF, there's an immediate question: where does the resulting data live while it waits to be sent to WhatsApp?&lt;/p&gt;

&lt;p&gt;This is a real infrastructure decision. I evaluated three options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A , In-memory (&lt;code&gt;BytesIO&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The PDF exists purely as bytes in the Python process's memory. No files written to disk, no cleanup required.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Advantages:&lt;/em&gt; Simplest implementation. No Disk I/O. No orphaned temporary files.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drawbacks:&lt;/em&gt; Memory is finite and volatile. Under concurrent load, large PDFs could create memory pressure. A worker crash before delivery means the PDF is gone permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B , Temporary file on the server&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write a &lt;code&gt;/tmp/daily-summary-[job-id].pdf&lt;/code&gt; file, send it, then delete it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Advantages:&lt;/em&gt; Doesn't consume application memory for stored documents. More manageable for very large files.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drawbacks:&lt;/em&gt; Disk I/O costs. Temporary files accumulate if a crash prevents cleanup. Multiple application instances don't share the same filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C , Object storage (e.g., S3)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Generate the PDF, upload it to a storage bucket, retrieve the URL for delivery.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Advantages:&lt;/em&gt; Durable, scalable, shared across all application instances. Enables report history and audit trails.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drawbacks:&lt;/em&gt; Network round-trip latency. Storage costs. Additional infrastructure and credential management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The choice:&lt;/strong&gt; Option A , in-memory delivery for the MVP.&lt;/p&gt;

&lt;p&gt;The spec's requirement is to generate a shop's daily summary (typically under 200KB) and immediately deliver it. That's a precise, time-bounded operation. Introducing S3 at this stage would add network latency, infrastructure cost, and credential complexity for a problem that in-memory delivery already solves cleanly. If report sizes grow or delivery patterns change, moving to Option C is a clear migration path , the &lt;code&gt;PDFService&lt;/code&gt; abstraction makes that swap a localized change, not a systemic one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database → Aggregate data → ReportLab → BytesIO → WhatsApp API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Extending the MessageSender
&lt;/h3&gt;

&lt;p&gt;Until Week 13, the &lt;code&gt;MessageSender&lt;/code&gt; protocol only knew how to send text. Adding document delivery meant extending the protocol with a &lt;code&gt;send_document&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;send_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;document_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;caption&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sender constructs an HTTP multipart request, posts the raw PDF bytes to the WhatsApp API, receives a media ID in return, and attaches that ID to an outbound message. WhatsApp's infrastructure handles the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Async Pipeline: Why Report Generation Is Architecturally Different
&lt;/h3&gt;

&lt;p&gt;Every other flow built in Month 3 , &lt;code&gt;ADD_PRODUCT&lt;/code&gt;, &lt;code&gt;RECORD_SALE&lt;/code&gt;, &lt;code&gt;CHECK_STOCK&lt;/code&gt; , followed the same pattern: message arrives, FSM transitions through states across multiple turns, business logic executes at &lt;code&gt;CONFIRM&lt;/code&gt;, session resets to &lt;code&gt;IDLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The report flow broke that pattern in an important way: &lt;strong&gt;it's genuinely one-turn, but the task is heavy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sales aggregation, PDF generation, and document delivery are not operations that belong inside a synchronous Celery task serving the &lt;code&gt;conversation_tasks&lt;/code&gt; queue. That queue is optimized for fast, lightweight FSM transitions. Tying it up with a potentially long-running report job would degrade response times for every other active conversation.&lt;/p&gt;

&lt;p&gt;My first instinct was to handle this like any other flow , hook it into the handler map and route by state. But that doesn't work. A standard flow looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Incoming message → FSM engine → dispatch by state → handler → transition → FSMResult
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the report, by the time the next message arrives, the job is already running in the background. There's no follow-up state to route to.&lt;/p&gt;

&lt;p&gt;The design I landed on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When the Intent Resolver detects &lt;code&gt;Intent.GENERATE_REPORT&lt;/code&gt; in the &lt;code&gt;IDLE&lt;/code&gt; handler, log the transient &lt;code&gt;REPORT_PENDING&lt;/code&gt; state to session history for observability purposes.&lt;/li&gt;
&lt;li&gt;Immediately delegate to &lt;code&gt;GenerateReportFlow.handle_daily_report(...)&lt;/code&gt; , a dispatcher that does exactly one thing: enqueue the job into &lt;code&gt;report_tasks&lt;/code&gt; and return.&lt;/li&gt;
&lt;li&gt;Return an immediate acknowledgement to the user: &lt;em&gt;"Your report is being generated and will arrive shortly."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;The session resets to &lt;code&gt;IDLE&lt;/code&gt;. The user can continue interacting.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;report_tasks&lt;/code&gt; worker picks up the job independently, generates the PDF, and delivers it asynchronously.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;REPORT_PENDING&lt;/code&gt; state is never used for routing. It exists purely as a breadcrumb in the session history , useful for debugging and observability, not for FSM logic.&lt;/p&gt;

&lt;p&gt;The result: the conversation worker is never blocked, the user isn't waiting, and the report arrives when it's ready.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 14 , Chaos Testing: Proving That SokoFlow Fails Gracefully
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "But I Already Have Tests. Why Do I Need a Chaos Runner?"
&lt;/h3&gt;

&lt;p&gt;Going into Week 14, SokoFlow had over 100 tests spanning unit and integration suites. State transitions, invalid inputs, cancellation flows, normal paths , all covered. The question that genuinely confused me was: &lt;em&gt;what gap is a chaos runner filling that those tests aren't?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer required stepping back and looking at what SokoFlow actually is.&lt;/p&gt;

&lt;p&gt;It's a system where every message arrives through a stateless HTTP webhook, gets validated, deduplicated, pushed into Celery, processed by an FSM, persisted across Redis and PostgreSQL, and sometimes triggers asynchronous background work. The components are distributed. The state is shared across processes. The inputs come from an unreliable external network.&lt;/p&gt;

&lt;p&gt;That changes the question from &lt;em&gt;"does this piece of code behave correctly?"&lt;/em&gt; to something harder:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Does SokoFlow remain correct when reality starts being annoying?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The individual components can all be correct while &lt;strong&gt;the system as a whole can still behave badly when things happen in the wrong order, happen twice, fail halfway through, or disappear temporarily.&lt;/strong&gt; That's the gap existing tests don't cover , and it's exactly the gap the chaos runner was designed for.&lt;/p&gt;

&lt;p&gt;I implemented &lt;code&gt;tools/chaos_runner.py&lt;/code&gt; as a dedicated script that runs against the &lt;strong&gt;actual local stack&lt;/strong&gt; , not mocked functions, not isolated units. It injects specific failure scenarios and checks whether the system preserves its important invariants.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Scenarios
&lt;/h3&gt;

&lt;h4&gt;
  
  
  DUPLICATE_MESSAGE
&lt;/h4&gt;

&lt;p&gt;WhatsApp can deliver the same message more than once under normal network conditions. The chaos runner sends the exact same &lt;code&gt;message_id&lt;/code&gt; twice and checks what &lt;em&gt;doesn't&lt;/em&gt; happen afterward.&lt;/p&gt;

&lt;p&gt;The duplicate must not create a second FSM transition, execute the business operation twice, or send another response. SokoFlow should acknowledge the second delivery and silently drop it. This tests the Redis deduplication layer end-to-end , not just the dedup function in isolation, but the entire path from webhook to worker to FSM to response gate.&lt;/p&gt;

&lt;h4&gt;
  
  
  DELAYED_DELIVERY
&lt;/h4&gt;

&lt;p&gt;A message arrives after the user's session TTL has expired in Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:    "add product"
System:  "What's the product name?"
         ... session expires (Redis TTL) ...
User:    "Milk 500ml"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dangerous failure mode here is the system resurrecting the old state and treating &lt;code&gt;"Milk 500ml"&lt;/code&gt; as an &lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt; response. Instead, an expired session should produce a clean IDLE initialisation: &lt;em&gt;"Your previous session timed out. Let's start fresh."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This matters because Redis TTLs are silent. There's no expiry event, no callback, no notification , the key simply disappears. The system has to handle that disappearance gracefully on the &lt;em&gt;next&lt;/em&gt; incoming message.&lt;/p&gt;

&lt;h4&gt;
  
  
  MALFORMED_JSON
&lt;/h4&gt;

&lt;p&gt;Send intentionally broken JSON to the webhook. The important check isn't just that FastAPI returns &lt;code&gt;422&lt;/code&gt; , it's that &lt;strong&gt;no Celery task is enqueued&lt;/strong&gt;. Invalid input should die at the front door. Business logic should never see it.&lt;/p&gt;

&lt;p&gt;This aligns directly with the architectural rule from Month 3: the webhook is responsible for validation and dispatch. The worker is responsible for business logic. Malformed input is the webhook's problem to reject, not the worker's problem to handle.&lt;/p&gt;

&lt;h4&gt;
  
  
  INVALID_HMAC
&lt;/h4&gt;

&lt;p&gt;Take a valid request payload and tamper with the HMAC signature. The expected path is strict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tampered request → HMAC verification → 401 → no task → no FSM transition → no database work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal FSM unit test doesn't prove this. The FSM can be perfectly implemented while the security boundary is completely absent. The chaos runner validates both at once.&lt;/p&gt;

&lt;h4&gt;
  
  
  WORKER_CRASH
&lt;/h4&gt;

&lt;p&gt;Inject an exception &lt;em&gt;after&lt;/em&gt; the session has been written to Redis but &lt;em&gt;before&lt;/em&gt; the outbound response is sent. When Celery retries the task, two invariants must hold: the FSM state must not roll backwards to its pre-transition position, and the retry must not execute the operation a second time.&lt;/p&gt;

&lt;p&gt;This directly tests the CAS (compare-and-swap) architecture from Month 3's Week 9. The Lua script's &lt;code&gt;expected_old_state&lt;/code&gt; check means a retry encountering an already-advanced state either continues safely or surfaces a &lt;code&gt;StateMismatchError&lt;/code&gt; , never silently re-applies a transition.&lt;/p&gt;

&lt;h4&gt;
  
  
  CONCURRENT_MESSAGES
&lt;/h4&gt;

&lt;p&gt;Submit five messages for the same phone number simultaneously. One phone number maps to one active FSM instance. If multiple Celery workers race to read and write the same session key without coordination, the conversation state becomes incoherent. The invariant being tested: exactly one valid state exists in Redis at the end of all five operations.&lt;/p&gt;

&lt;h4&gt;
  
  
  DATABASE_FAILURE
&lt;/h4&gt;

&lt;p&gt;Pause PostgreSQL in the middle of a task. The worker should fail, retry with exponential backoff, and succeed once Postgres recovers , without leaving corrupted application state. Structured Celery retry configuration looks correct in code; the chaos runner verifies it works against the actual running database.&lt;/p&gt;

&lt;h4&gt;
  
  
  INVALID_FSM_LOOP
&lt;/h4&gt;

&lt;p&gt;Send invalid input repeatedly while the FSM holds a state awaiting a specific input type , for example, feeding non-numeric strings to &lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;. After three consecutive failures in the same state, the FSM should force-reset to IDLE and apologise. This scenario verifies that guard, end-to-end, with real messages through the full stack.&lt;/p&gt;

&lt;p&gt;Without this, a shopkeeper who accidentally types the wrong thing three times in a row is trapped in an infinite re-prompt loop , one of the most frustrating experiences possible in a conversational interface.&lt;/p&gt;

&lt;h4&gt;
  
  
  REDIS_UNAVAILABILITY
&lt;/h4&gt;

&lt;p&gt;Pause Redis while a conversation is active. Redis isn't just a cache in SokoFlow , it's session memory, Celery's message broker, and the deduplication store. Losing Redis means losing the FSM's ability to remember anything. The test verifies graceful degradation and correct recovery once Redis comes back online.&lt;/p&gt;

&lt;h4&gt;
  
  
  REPORT_TIMEOUT
&lt;/h4&gt;

&lt;p&gt;Inject a timeout into document delivery during the async report path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Report task → generate PDF → delivery fails → retry → idempotency check → safe continuation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry check isn't just &lt;em&gt;"does it try again?"&lt;/em&gt; It's &lt;em&gt;"does trying again produce a duplicate report?"&lt;/em&gt; Each mutating Celery task carries an idempotency key. The chaos runner validates both sides: that the retry fires correctly, and that idempotency prevents duplicate delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Results
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ uv run -m tools.chaos_runner

SokoFlow Chaos Testing Suite v1.0
============================================================
Environment: local
Started: 2026-09-09T10:59:33.613254+00:00

Running scenario: DUPLICATE_MESSAGE (1/10)       PASSED (0.23s)
Running scenario: DELAYED_DELIVERY (2/10)        PASSED (3.03s)
Running scenario: MALFORMED_JSON (3/10)          PASSED (0.04s)
Running scenario: INVALID_HMAC (4/10)            PASSED (0.03s)
Running scenario: WORKER_CRASH (5/10)            PASSED (0.03s)
Running scenario: CONCURRENT_MESSAGES (6/10)     PASSED (1.12s)
Running scenario: DATABASE_FAILURE (7/10)        PASSED (8.34s)
Running scenario: INVALID_FSM_LOOP (8/10)        PASSED (0.67s)
Running scenario: REDIS_UNAVAILABILITY (9/10)    PASSED (12.44s)
Running scenario: REPORT_TIMEOUT (10/10)         PASSED (14.03s)

Summary: 10/10 passed | Duration: 40.0s | Coverage: 100.0%
Report saved: chaos_reports/chaos_2026-09-09T11-00-13.html
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Reframe That Changed Everything
&lt;/h3&gt;

&lt;p&gt;My original question going into this week , &lt;em&gt;"why do I need chaos testing when I already have unit tests?"&lt;/em&gt; , had the wrong premise. The better question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"What kinds of failures can my existing tests structurally not see?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Unit tests answer: &lt;em&gt;"does this piece of code behave correctly in isolation?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Chaos tests answer: &lt;em&gt;"does the system remain correct when a Celery task retries, Redis temporarily disappears, and another message arrives for the same phone number , all at the same time?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those are completely different questions. Both matter. Neither replaces the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 15 , Internationalization and Low-Stock Alerts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Internationalization (i18n)
&lt;/h3&gt;

&lt;p&gt;The spec required that a shop registered with &lt;code&gt;locale='sw'&lt;/code&gt; receives Swahili responses, while &lt;code&gt;locale='en'&lt;/code&gt; receives English. Straightforward requirement. The interesting engineering challenge was figuring out &lt;em&gt;where in the architecture this belongs&lt;/em&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Scoping the Problem First
&lt;/h4&gt;

&lt;p&gt;Before touching any code, I had to decide: inbound Swahili parsing, or outbound Swahili responses only?&lt;/p&gt;

&lt;p&gt;Inbound internationalization , understanding &lt;em&gt;incoming&lt;/em&gt; Swahili messages like &lt;code&gt;"uzia maziwa 3"&lt;/code&gt; , would require redesigning the entire Intent Resolver. That's a separate, significant project. It would also expand the intent-resolution problem rather than implement the i18n requirement.&lt;/p&gt;

&lt;p&gt;The scope for Week 15 was &lt;strong&gt;outbound messages only&lt;/strong&gt;: whatever the shop's locale, the FSM responses go out in the correct language. No changes to incoming message parsing.&lt;/p&gt;

&lt;h4&gt;
  
  
  Finding the Right Architectural Boundary
&lt;/h4&gt;

&lt;p&gt;With scope defined, the next question: &lt;em&gt;where does localization live?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most obvious place was inside the FSM handlers themselves. Each handler already returns an &lt;code&gt;FSMResult&lt;/code&gt; with a &lt;code&gt;reply_text&lt;/code&gt; field. Why not just make that text language-aware?&lt;/p&gt;

&lt;p&gt;The problem: hardcoding localized strings across 20+ FSM flows means that adding a new locale in the future requires editing every handler. More critically, it means the FSM is doing two jobs at once , determining state transitions &lt;em&gt;and&lt;/em&gt; producing presentation text. Those are different responsibilities, and mixing them creates the coupling that makes systems brittle.&lt;/p&gt;

&lt;p&gt;The right answer kept pointing to the same place: &lt;strong&gt;the edge&lt;/strong&gt;. Specifically, the &lt;code&gt;MessageSender&lt;/code&gt; , the one layer every outbound message passes through before it leaves the system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4noag7jgosnnhsrlikj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4noag7jgosnnhsrlikj3.png" alt="Localization boundary diagram: FSM/Services → semantic message (message_key + params) → Localization layer → Shop.locale → localized text → MessageSender → WhatsApp" width="799" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This keeps the FSM entirely unaware of language. The FSM produces &lt;em&gt;meaning&lt;/em&gt;. The localization layer translates meaning into the correct language. The &lt;code&gt;MessageSender&lt;/code&gt; delivers it.&lt;/p&gt;

&lt;h4&gt;
  
  
  The FSMResult Refactor
&lt;/h4&gt;

&lt;p&gt;This is where things got architecturally interesting. The existing &lt;code&gt;FSMResult&lt;/code&gt; looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;FSMResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;previous_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;new_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;reply_text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sale recorded successfully&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# ← English hardcoded in business logic
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;code&gt;reply_text&lt;/code&gt; is a presentation decision baked into a domain object. The FSM is saying &lt;em&gt;how to say something&lt;/em&gt; instead of &lt;em&gt;what happened&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The refactored version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;FSMResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;previous_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;new_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;message_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MessageKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SALE_RECORDED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;# ← what happened
&lt;/span&gt;    &lt;span class="n"&gt;message_params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;                          &lt;span class="c1"&gt;# ← the data to render it with
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Milk 500ml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the outbound boundary, &lt;code&gt;message_key + message_params + shop.locale&lt;/code&gt; renders the final text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Sale of 5 Milk 500ml recorded. Remaining stock: 12 units."&lt;/span&gt;
&lt;span class="py"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Mauzo ya Milk 500ml 5 yamehifadhiwa. Stoke iliyobaki: vitengo 12."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the principle i18n exposed that was always latent in the design:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Application code should declare what happened. Not how to say it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One key decision I was careful about: the localization logic lives in a dedicated &lt;code&gt;localization.py&lt;/code&gt; service, not directly inside &lt;code&gt;MessageSender&lt;/code&gt;. Transport responsibility and localization responsibility are different things, and conflating them would just move the coupling problem from one place to another.&lt;/p&gt;

&lt;h4&gt;
  
  
  What i18n Actually Taught Me
&lt;/h4&gt;

&lt;p&gt;Features like internationalization are worth building not just for the user-facing value , they expose architectural boundaries that were previously invisible. The original &lt;code&gt;FSMResult&lt;/code&gt; design wasn't wrong. It was correct and efficient for one language and fast iteration. But it had a hidden assumption: that presentation and domain logic were the same thing. i18n made that assumption impossible to ignore.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low-Stock Alerts
&lt;/h3&gt;

&lt;p&gt;The spec promised shop owners an alert when a product crosses its defined low-stock threshold. The interesting design question was deceptively simple: &lt;em&gt;what exactly is the event that triggers the alert?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The naive approach , send an alert after every sale if stock is currently below threshold , sends multiple alerts for a product that stays low across several consecutive sales. If a threshold is 10 and stock goes 12 → 8 → 6 → 4, the naive approach fires three times. That's spam.&lt;/p&gt;

&lt;p&gt;The correct approach is &lt;strong&gt;threshold crossing detection&lt;/strong&gt; , the alert fires only when a sale transitions a product from &lt;em&gt;above&lt;/em&gt; to &lt;em&gt;below&lt;/em&gt; the threshold. Not when it's already below and goes lower.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;old_is_low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventory&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;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;low_stock_threshold&lt;/span&gt;

&lt;span class="c1"&gt;# Deduct inventory for the sale
&lt;/span&gt;
&lt;span class="n"&gt;new_is_low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventory&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;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;low_stock_threshold&lt;/span&gt;

&lt;span class="n"&gt;low_stock_triggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;old_is_low&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_is_low&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stock Before&lt;/th&gt;
&lt;th&gt;Stock After&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Above threshold&lt;/td&gt;
&lt;td&gt;Above threshold&lt;/td&gt;
&lt;td&gt;❌ No alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Above threshold&lt;/td&gt;
&lt;td&gt;Below threshold&lt;/td&gt;
&lt;td&gt;✅ Alert fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below threshold&lt;/td&gt;
&lt;td&gt;Below threshold&lt;/td&gt;
&lt;td&gt;❌ No alert (already alerted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below threshold&lt;/td&gt;
&lt;td&gt;Above threshold&lt;/td&gt;
&lt;td&gt;❌ No alert&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the &lt;code&gt;False → True&lt;/code&gt; transition fires the alert. It's detecting a &lt;strong&gt;state change event&lt;/strong&gt;, not a condition being true. The alert enqueues as a Celery task into &lt;code&gt;report_tasks&lt;/code&gt;, which handles delivery through the same localized &lt;code&gt;MessageSender&lt;/code&gt; pipeline built for i18n.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 16 , Final Integration: GUI Simulator, Demo, and Shipping
&lt;/h2&gt;

&lt;p&gt;The final week was deliberately narrow. The project was functionally complete. Week 16 was about integration, documentation, and one addition I hadn't anticipated: a visual demo interface.&lt;/p&gt;

&lt;h3&gt;
  
  
  The GUI Chat Simulator
&lt;/h3&gt;

&lt;p&gt;For 15 weeks, I'd been testing conversations through a CLI REPL , a terminal process that mimicked a WhatsApp session. It worked perfectly for development. For a demo, it looked like a developer tool, not a product.&lt;/p&gt;

&lt;p&gt;The decision: build a lightweight web-based chat simulator that &lt;em&gt;looks&lt;/em&gt; like a WhatsApp conversation, wired to the actual SokoFlow backend. The constraints were strict , final week means shipping, not feature-adding. No new backend endpoints, no new dependencies.&lt;/p&gt;

&lt;p&gt;The stack: &lt;strong&gt;pure HTML, CSS, and vanilla JavaScript.&lt;/strong&gt; Python's built-in &lt;code&gt;http.server&lt;/code&gt; to serve static files and handle API requests. No FastAPI changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy7da467c7ijljoufmu73.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy7da467c7ijljoufmu73.png" alt="Chat simulator architecture: Browser UI ↔ Simulator server (tools/chat_simulator.py) ↔ SokoFlow FastAPI webhook ↔ Celery workers ↔ Redis/PostgreSQL" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How a message flows from the browser to SokoFlow and back:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound (user → SokoFlow):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User types a message in the browser UI.&lt;/li&gt;
&lt;li&gt;UI posts &lt;code&gt;{ phone, message }&lt;/code&gt; to the simulator's &lt;code&gt;POST /api/send&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;The simulator constructs a valid &lt;code&gt;WhatsAppWebhook&lt;/code&gt; payload, signs it with HMAC-SHA256 (&lt;code&gt;X-Hub-Signature-256&lt;/code&gt;), and forwards it to &lt;code&gt;http://localhost:8000/webhook/whatsapp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;FastAPI validates the payload, verifies the signature, deduplicates via Redis, and dispatches to the appropriate Celery queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Outbound (SokoFlow → browser):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When the Celery worker completes processing, &lt;code&gt;MockMessageSender.send_text()&lt;/code&gt; or &lt;code&gt;send_document()&lt;/code&gt; POSTs the response to the simulator's receiver endpoint.&lt;/li&gt;
&lt;li&gt;The simulator appends the message to an in-memory store keyed by phone number.&lt;/li&gt;
&lt;li&gt;The browser polls &lt;code&gt;GET /api/messages?phone=...&lt;/code&gt; every second and renders new messages as chat bubbles.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The backend never knew the difference. Every message went through the real webhook, the real HMAC verification, the real FSM, the real database. The simulator was a cosmetic wrapper around the same production-shaped infrastructure that Month 2 built.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Demo
&lt;/h3&gt;

&lt;p&gt;I recorded an 11-minute walkthrough using Loom covering the full system , adding products, recording sales, generating a PDF report, and demonstrating FSM edge cases including invalid input recovery and session cancellation. You can watch it &lt;a href="https://www.loom.com/share/2aff352666d24821b91cb99c7373b33b" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker and README
&lt;/h3&gt;

&lt;p&gt;The Docker work was largely done in Month 2 , multi-stage builds, an optimised image under 200MB, and a &lt;code&gt;docker-compose.yml&lt;/code&gt; orchestrating FastAPI, PostgreSQL 15, Redis 7, and the Celery worker pools. Week 16 ensured everything was documented clearly enough that a fresh clone reached a running system in under 5 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Journey: All Four Months
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;th&gt;Month 3&lt;/th&gt;
&lt;th&gt;Month 4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Focus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business core&lt;/td&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Conversational engine&lt;/td&gt;
&lt;td&gt;Integration + polish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local development&lt;/td&gt;
&lt;td&gt;Containerized + cloud&lt;/td&gt;
&lt;td&gt;Stateful conversations&lt;/td&gt;
&lt;td&gt;Fully integrated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Testing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TDD, 40+ unit tests&lt;/td&gt;
&lt;td&gt;CI pipeline, 85% gate&lt;/td&gt;
&lt;td&gt;FSM unit + integration&lt;/td&gt;
&lt;td&gt;100+ tests + chaos suite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key technology&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FastAPI, PostgreSQL, Alembic&lt;/td&gt;
&lt;td&gt;Docker, GitHub Actions, Railway&lt;/td&gt;
&lt;td&gt;Redis FSM, Lua CAS, pg_trgm, HMAC&lt;/td&gt;
&lt;td&gt;ReportLab, i18n, chaos runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State of system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs locally&lt;/td&gt;
&lt;td&gt;Runs in the cloud&lt;/td&gt;
&lt;td&gt;Holds a conversation&lt;/td&gt;
&lt;td&gt;Deployed, tested, documented&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Thoughts: What 16 Weeks Actually Taught Me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Answer to the LLM Question
&lt;/h3&gt;

&lt;p&gt;In Month 1, I said SokoFlow wouldn't use an LLM for intent parsing. In Month 3, I said the explanation would come in Month 4.&lt;/p&gt;

&lt;p&gt;Here it is.&lt;/p&gt;

&lt;p&gt;An LLM would have resolved &lt;code&gt;"uzia maziwa 3"&lt;/code&gt; into &lt;code&gt;RECORD_SALE, product=milk, qty=3&lt;/code&gt; in milliseconds. It would have handled typos, ambiguity, and Swahili code-switching out of the box. It would have been faster to build.&lt;/p&gt;

&lt;p&gt;The reason I didn't: &lt;strong&gt;SokoFlow deals with real inventory and real sales for real shop owners.&lt;/strong&gt; When a system records that 5 units of milk were sold, that number needs to be reliable. An LLM's output is probabilistic , it produces the most &lt;em&gt;likely&lt;/em&gt; answer, not a guaranteed one. Debugging a hallucination in production at a shop owner's expense is not an acceptable failure mode.&lt;/p&gt;

&lt;p&gt;The FSM approach gives me something the LLM approach cannot: &lt;strong&gt;every decision is traceable.&lt;/strong&gt; If a sale is recorded incorrectly, I can read the state transition log and find exactly where the interpretation went wrong. There's no probability distribution to interrogate, no temperature to tune. The system is deterministic by design, and for a system that moves inventory and revenue, that's not a constraint , it's a feature.&lt;/p&gt;

&lt;p&gt;That said, "no LLMs" was a design choice for SokoFlow's MVP, not a position. The right tool depends on the problem. Which is exactly why Month 5 will add one carefully scoped AI-powered feature , and why that distinction matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bigger Lesson
&lt;/h3&gt;

&lt;p&gt;Before this project, I knew the names of these tools. FastAPI, Celery, Redis, Docker, PostgreSQL , I could have listed them in a sentence. What I didn't have was any intuition for &lt;em&gt;why&lt;/em&gt; a system is designed the way it is. Why the webhook is dumb and the worker is smart. Why naive datetimes are a silent landmine. Why localization belongs at the edge and not inside business logic. Why chaos testing asks different questions than unit testing.&lt;/p&gt;

&lt;p&gt;Those aren't things you learn from documentation. They show up when you build something real and the wrong design creates a problem you have to solve.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's Next
&lt;/h3&gt;

&lt;p&gt;SokoFlow is complete as an MVP. But it doesn't end here.&lt;/p&gt;

&lt;p&gt;For the next month, I'll be adding one AI-powered feature on top of the existing FSM foundation: &lt;strong&gt;natural-language shop analytics&lt;/strong&gt;. A shopkeeper will be able to ask free-form questions like &lt;em&gt;"which products are running low?"&lt;/em&gt; or &lt;em&gt;"how does this week compare to last week?"&lt;/em&gt; , and an LLM, using function calling against SokoFlow's existing internal APIs, will resolve the question into a real query and a grounded answer.&lt;/p&gt;

&lt;p&gt;No new data store. No separate product. The AI layer is additive , it works &lt;em&gt;with&lt;/em&gt; the deterministic FSM, not instead of it.&lt;/p&gt;

&lt;p&gt;That's the right way to introduce AI into a system: find a specific problem it solves better than rules-based logic, add it at the edge, and keep the core predictable.&lt;/p&gt;

&lt;p&gt;Keep following for when that episode drops.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;SokoFlow is open source. The full codebase, architecture diagrams, and documentation are available on &lt;a href="https://github.com/paul-murithi/SokoFlow" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>Teaching SokoFlow to Remember: Building a Conversational Engine with FSMs, Redis, and WhatsApp Webhooks</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Sun, 23 Aug 2026 09:20:52 +0000</pubDate>
      <link>https://dev.to/paulmurithi/teaching-sokoflow-to-remember-building-a-conversational-engine-with-fsms-redis-and-whatsapp-56mp</link>
      <guid>https://dev.to/paulmurithi/teaching-sokoflow-to-remember-building-a-conversational-engine-with-fsms-redis-and-whatsapp-56mp</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 3 of 4
&lt;/h3&gt;




&lt;p&gt;Welcome back. If you're new here — I'm an IT student running a structured, project-based learning plan toward becoming a production-grade backend engineer. Last semester I built &lt;a href="https://simpesa-docs.vercel.app/" rel="noopener noreferrer"&gt;SimPesa&lt;/a&gt;, a local-first M-Pesa STK Push simulator. This semester, the theme is &lt;em&gt;from controlled environments to the messy real world&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SokoFlow&lt;/strong&gt; is my flagship project: a conversational ERP for small Kenyan shopkeepers that lets them track inventory and record sales entirely through WhatsApp chat — no app, no training, just natural language.&lt;/p&gt;

&lt;p&gt;Month 1 was the business core, built with strict TDD. Month 2 was infrastructure — Docker, CI/CD, and a live staging deployment on Railway. If you haven't read those, I covered them in the previous build logs.&lt;/p&gt;

&lt;p&gt;Month 3 was the part I'd been building toward since the beginning: &lt;strong&gt;the Conversation Engine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At the end of the Month 1 blog, I left off with a question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you turn messy human language into deterministic system actions without the whole thing becoming cursed spaghetti?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer I committed to: &lt;strong&gt;Finite State Machines (FSMs) + controlled intent parsing + conversational context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And then you might be wondering — why not just throw an LLM at this? That answer is coming. Keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goals for Month 3
&lt;/h2&gt;

&lt;p&gt;The acceptance criteria going into this month were specific and measurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session survives a worker restart. An expired session resets to IDLE correctly.&lt;/li&gt;
&lt;li&gt;Full add-product flow completes via chat simulator in fewer than 5 messages.&lt;/li&gt;
&lt;li&gt;A sale is recorded correctly for an ambiguous product name (e.g., &lt;code&gt;"milk"&lt;/code&gt; → &lt;code&gt;"Milk 500ml"&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Simulator payload accepted. Invalid signature returns 401. Duplicate dropped silently.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Prerequisites: Two Mini-Projects Before the Real Work
&lt;/h2&gt;

&lt;p&gt;Before starting any FSM implementation, there were two self-contained tools I had to build first. These weren't part of the weekly milestones — they were infrastructure the rest of the month depended on. Both were interesting enough that I'll dedicate a separate post to each. For now, here's the short version.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Chat Simulator
&lt;/h3&gt;

&lt;p&gt;Relying on a real WhatsApp API during local development would have been painful. So I built a &lt;strong&gt;CLI tool that mimics a WhatsApp conversation&lt;/strong&gt; directly from the terminal.&lt;/p&gt;

&lt;p&gt;After exploring a few options, I settled on an &lt;strong&gt;interactive REPL with a local webhook server&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CLI step turns the simulator into a long-running process and spins up a lightweight background HTTP server using Python's built-in &lt;code&gt;http.server&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The worker step: when Celery finishes processing a state transition, it fires a standard HTTP POST request back to that background server.&lt;/li&gt;
&lt;li&gt;The loop closes: the running CLI thread captures the inbound payload and prints it to the terminal instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a single terminal screen that behaves exactly like an active WhatsApp conversation — without needing a phone, a verified business account, or any real API calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Intent Resolver
&lt;/h3&gt;

&lt;p&gt;When a user's message hits the system, it's raw natural language. &lt;code&gt;"sold milk"&lt;/code&gt;, &lt;code&gt;"check stock"&lt;/code&gt;, &lt;code&gt;"Hi"&lt;/code&gt; — the system needs to decide what the user &lt;em&gt;wants&lt;/em&gt; before deciding what to &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Intent Resolver&lt;/strong&gt; sits in front of the FSM and classifies incoming text into a discrete intent the system can act on. Think of it as a receptionist at a building entrance. The receptionist doesn't solve your problem — they just say &lt;em&gt;"You're here for Finance"&lt;/em&gt; or &lt;em&gt;"You're here for HR"&lt;/em&gt; and point you to the right floor.&lt;/p&gt;

&lt;p&gt;The resolver maps something like &lt;code&gt;"Sale"&lt;/code&gt; into &lt;code&gt;Intent.RECORD_SALE&lt;/code&gt;. The FSM takes it from there.&lt;/p&gt;

&lt;p&gt;Crucially, the Intent Resolver is &lt;strong&gt;only active when the FSM is in the &lt;code&gt;IDLE&lt;/code&gt; state&lt;/strong&gt;. Once a user is inside a flow like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ADD_PRODUCT_NAME → ADD_PRODUCT_PRICE → ADD_PRODUCT_QTY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...the current state provides all the context needed. There's no ambiguity to resolve.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 9 — Redis Session Layer: FSM State Serialization, TTL Handling, and Dedup Keys
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Core Problem: HTTP Has No Memory
&lt;/h3&gt;

&lt;p&gt;The key mental model to start with: &lt;strong&gt;HTTP is stateless by design.&lt;/strong&gt; If a user sends a message to SokoFlow, the system processes it and responds. When the next message arrives seconds later, the system has no idea who this person is or what they were doing.&lt;/p&gt;

&lt;p&gt;For a back-and-forth conversation to work, the system needs persistent memory between requests. That points to a database — but not just any database. It needs to be fast and lightweight. Everything pointed to &lt;strong&gt;Redis&lt;/strong&gt;, an in-memory data store already in SokoFlow's stack as the Celery broker. No new dependencies, just a Redis client and a clear schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Session Schema: Pydantic vs. Dataclasses
&lt;/h3&gt;

&lt;p&gt;The first implementation decision was how to model the session structure. In Python, this immediately surfaces the Pydantic vs. dataclasses debate. They look similar on the surface — both package typed fields — but they serve fundamentally different purposes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dataclasses&lt;/strong&gt; are for clean internal data structures. &lt;strong&gt;Pydantic&lt;/strong&gt; is for enforcing schema integrity across untrusted boundaries.&lt;/p&gt;

&lt;p&gt;Redis stores raw strings. Celery workers read raw messages off a queue. These are untrusted boundaries. Pydantic wins here for four concrete reasons:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automatic type coercion&lt;/td&gt;
&lt;td&gt;Converts raw string timestamps from Redis directly into &lt;code&gt;datetime&lt;/code&gt; objects — no manual parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested deserialization&lt;/td&gt;
&lt;td&gt;Rebuilds complex nested JSON into deep Python objects automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime validation&lt;/td&gt;
&lt;td&gt;Guarantees Celery workers never process corrupted or malformed session state — raises &lt;code&gt;ValidationError&lt;/code&gt; before business logic touches bad data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native JSON compatibility&lt;/td&gt;
&lt;td&gt;Serializes complex Python types directly into JSON strings Redis can store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Handling Edge Cases from the Start
&lt;/h3&gt;

&lt;p&gt;Before writing the first handler, I defined how the system behaves when things go sideways. These aren't afterthoughts — they're first-class design decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User Interruptions.&lt;/strong&gt; If a user sends &lt;code&gt;cancel&lt;/code&gt; or &lt;code&gt;menu&lt;/code&gt; from any non-IDLE state, the FSM transitions unconditionally to &lt;code&gt;IDLE&lt;/code&gt;, clears the working context, and confirms cancellation. This override works from &lt;em&gt;any&lt;/em&gt; state, no exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session Expiry.&lt;/strong&gt; When a session's TTL expires in Redis, the next message from that phone number finds nothing. The FSM initialises fresh from IDLE with: &lt;em&gt;"Your previous session timed out. Let's start fresh."&lt;/em&gt; Partial operations are never committed to PostgreSQL until the &lt;code&gt;CONFIRM&lt;/code&gt; state is explicitly reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalid Inputs.&lt;/strong&gt; Each state defines its own acceptance criteria. If a user enters a non-numeric value when a price is expected, the FSM holds the current state and re-prompts with a helpful message, incrementing an error counter. After 3 consecutive invalid inputs in the same state, the FSM transitions to IDLE with an apology — preventing infinite loops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate Messages.&lt;/strong&gt; WhatsApp can deliver the same message multiple times under poor network conditions. Each incoming message is checked against a Redis set of recently processed message IDs (TTL: 60 seconds). If the &lt;code&gt;message_id&lt;/code&gt; already exists in the set, the request is acknowledged with &lt;code&gt;200 OK&lt;/code&gt; and silently dropped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dedup:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NX&lt;/code&gt; flag makes this atomic — the key is set only if it doesn't already exist, which is exactly the check we need.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ConversationStore: Abstracting Redis Away
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ConversationStore&lt;/code&gt; is the &lt;strong&gt;Data Access Object (DAO)&lt;/strong&gt; for session management. Its job is to completely hide Redis implementation details from the rest of the application. The FSM engine and Celery tasks don't care &lt;em&gt;how&lt;/em&gt; sessions are saved — they just call the store.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_session(phone)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fetches raw string from Redis, parses JSON into a Pydantic model, returns &lt;code&gt;None&lt;/code&gt; on cache miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_session(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updates session state using atomic compare-and-swap to prevent race conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_session(phone)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes the key explicitly when a user completes a flow or cancels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;save_session&lt;/code&gt; method is where things get interesting — and where the most important architectural decision of the week lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Race Condition Problem: Why &lt;code&gt;save_session&lt;/code&gt; Needed Special Treatment
&lt;/h3&gt;

&lt;p&gt;Anywhere a system reads state, modifies it in memory, and writes it back is dangerous. The pattern looks innocent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read state → modify in memory → write state back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the state you read may no longer be the state that exists when you write. The classic failure mode is a &lt;strong&gt;lost update&lt;/strong&gt;: two workers read the same state simultaneously, both modify it independently, and the second write overwrites the first — producing a state that was never a valid transition.&lt;/p&gt;

&lt;p&gt;Imagine two requests arriving near-simultaneously from the same phone number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A reads state: IDLE
Worker B reads state: IDLE
Worker A transitions to ADD_PRODUCT_NAME in memory
Worker B transitions to ADD_PRODUCT_NAME in memory
Worker A writes ADD_PRODUCT_NAME → correct
Worker B writes ADD_PRODUCT_NAME → overwrites A's work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the FSM, the next valid state was &lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;, not a second &lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;. The conversation is now corrupt.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Redis Lua Scripting (Optimistic CAS)
&lt;/h3&gt;

&lt;p&gt;I had two genuine options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1 — Optimistic CAS via Lua Script:&lt;/strong&gt; At write time, atomically verify that the state in Redis still matches what the worker expected. If another worker has already advanced the state, the write fails and the error is handled explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2 — Pessimistic Distributed Locking:&lt;/strong&gt; Acquire a Redis lock on the user's phone number at the &lt;em&gt;beginning&lt;/em&gt; of task processing, preventing any concurrent access.&lt;/p&gt;

&lt;p&gt;I chose Option 1. Here's why Option 2 was the wrong pick for this architecture:&lt;/p&gt;

&lt;p&gt;SokoFlow is built on the principle of a &lt;em&gt;dumb webhook receiver and a smart async worker pool&lt;/em&gt; (Principle 4 in the Developer Manifesto: "Failure Is a First-Class Input"). Pessimistic locking introduces a hard coordination problem between the lock TTL, the Celery task timeout, and the 30-minute Redis session TTL. If a worker crashes while holding a lock, that user's entire chat session is frozen until the lock expires — a terrible experience for an SME user who's in the middle of recording a sale.&lt;/p&gt;

&lt;p&gt;Optimistic CAS aligns with the architecture naturally. Concurrent messages from the same phone number are rare (the deduplication layer already handles retransmissions). When a CAS failure does occur, the recovery path is explicit: fetch the new state, log the conflict with a &lt;code&gt;correlation_id&lt;/code&gt;, and re-prompt the user — no blocked threads, no frozen sessions.&lt;/p&gt;

&lt;p&gt;The implementation uses a &lt;strong&gt;Lua script registered at startup&lt;/strong&gt; that executes atomically inside Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save_session(phone, expected_old_state, new_payload, ttl=1800) → bool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lua script receives three arguments: the expected old state, the new serialized session JSON, and the TTL. It compares the current state in Redis against the expected state before writing. The return codes map directly to typed domain exceptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; → Success&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; → &lt;code&gt;StateMismatchError&lt;/code&gt; (stale state — another worker advanced it first)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-1&lt;/code&gt; → &lt;code&gt;CorruptedSessionError&lt;/code&gt; (invalid JSON in Redis)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbry14mw5ix3ixusnyq7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbry14mw5ix3ixusnyq7q.png" alt="Session CAS flow" width="800" height="889"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 10 — FSM Core: The ADD_PRODUCT Flow End-to-End
&lt;/h2&gt;

&lt;p&gt;With the session layer solid, Week 10 was about wiring up the actual state machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architectural Strategy: State Pattern over if-else Chains
&lt;/h3&gt;

&lt;p&gt;The naive approach to routing a message to its handler looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IDLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ADD_PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ADD_PRODUCT_PRICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works until the FSM has 20 states. Then &lt;code&gt;process_message()&lt;/code&gt; becomes a 300-line method that knows everything. Adding, removing, or renaming a state means surgically editing one giant block.&lt;/p&gt;

&lt;p&gt;The better approach is a &lt;strong&gt;dispatch table&lt;/strong&gt; — a dictionary that maps each state directly to its handler function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;IDLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_idle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_PRICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_QTY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CONFIRM_ADD_PRODUCT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_confirm_add_product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&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;handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine now answers exactly one question: &lt;em&gt;which handler belongs to this state?&lt;/em&gt; Adding a new state means adding one line to the map and writing the handler independently. Each handler can evolve, be tested, and be reasoned about in complete isolation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The dispatch table keeps the engine generic and the state-specific logic where it belongs: in the handler.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every message turn through the engine follows a strict pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-processing (Universal Guards):&lt;/strong&gt; Check for global commands — &lt;code&gt;cancel&lt;/code&gt;, &lt;code&gt;menu&lt;/code&gt;, &lt;code&gt;exit&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Evaluation:&lt;/strong&gt; Look up the current &lt;code&gt;SessionState&lt;/code&gt; in the dispatch table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input Validation:&lt;/strong&gt; Validate the message according to that state's rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Transition &amp;amp; Context Mutation:&lt;/strong&gt; On valid input, update &lt;code&gt;UserSession.context&lt;/code&gt; and advance &lt;code&gt;UserSession.state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error Counter Management:&lt;/strong&gt; On invalid input, increment &lt;code&gt;context.error_count&lt;/code&gt;. At 3 consecutive failures, force reset to IDLE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic Persistence:&lt;/strong&gt; Save the updated session back to Redis via &lt;code&gt;ConversationStore.save_session()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The ADD_PRODUCT State Blueprint
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y1as4iw7zw9vqs1fawu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y1as4iw7zw9vqs1fawu.png" alt="ADD_PRODUCT FSM state transition diagram" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Current State&lt;/th&gt;
&lt;th&gt;Input Accepted&lt;/th&gt;
&lt;th&gt;Target State&lt;/th&gt;
&lt;th&gt;System Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intent trigger (&lt;code&gt;"add product"&lt;/code&gt;, &lt;code&gt;"add"&lt;/code&gt;, &lt;code&gt;"new product"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;"What is the product name? (e.g., Milk 500ml)"&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-empty string (2–100 chars)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores name. &lt;em&gt;"What is the selling price in KES?"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Positive float/decimal (&amp;gt; 0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_QTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores price. &lt;em&gt;"How many units in stock?"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_QTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Positive integer (≥ 0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores qty. &lt;em&gt;"Confirm add: {name} at KES {price}, {qty} units? (yes/no)"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"yes"&lt;/code&gt; / &lt;code&gt;"y"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Commits product to Postgres, clears context, returns success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"no"&lt;/code&gt; / &lt;code&gt;"n"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clears context, restarts from name prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANY NON-IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"cancel"&lt;/code&gt; / &lt;code&gt;"menu"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resets state, clears context, confirms cancellation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANY NON-IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 consecutive invalid inputs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resets state, apologises, sends guidance message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Input Validators: Handling the Messy Reality of Kenyan SME Text
&lt;/h3&gt;

&lt;p&gt;Before wiring up the engine, I wrote pure, stateless helper functions to clean up the real-world text a shopkeeper would type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parse_product_name(raw_text)&lt;/code&gt; — trims whitespace, validates 2–100 character length.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_price(raw_text)&lt;/code&gt; — handles Kenyan currency formats like &lt;code&gt;"KES 150"&lt;/code&gt;, &lt;code&gt;"150/="&lt;/code&gt;, and plain &lt;code&gt;"150"&lt;/code&gt;. Parses to &lt;code&gt;Decimal&lt;/code&gt; and validates greater than zero.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_quantity(raw_text)&lt;/code&gt; — parses positive integers, validates ≥ 0.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_confirmation(raw_text)&lt;/code&gt; — accepts positive triggers (&lt;code&gt;"yes"&lt;/code&gt;, &lt;code&gt;"y"&lt;/code&gt;, &lt;code&gt;"ndio"&lt;/code&gt;, &lt;code&gt;"1"&lt;/code&gt;) and negative triggers (&lt;code&gt;"no"&lt;/code&gt;, &lt;code&gt;"n"&lt;/code&gt;, &lt;code&gt;"zii"&lt;/code&gt;, &lt;code&gt;"2"&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the entry points to every state transition. They raise typed &lt;code&gt;InvalidInputError&lt;/code&gt; with user-facing messages, so the FSM engine never has to write error messages itself — the validators do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 5-Message Execution Flow, End-to-End
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:  "add product"
FSM:   IDLE → ADD_PRODUCT_NAME       | "Great, let's add a product. What is the product name?"

User:  "Uji Flour 2kg"
FSM:   ADD_PRODUCT_NAME → ADD_PRODUCT_PRICE  | "Nice. What is the price in KES?"

User:  "150/="
FSM:   ADD_PRODUCT_PRICE → ADD_PRODUCT_QTY  | "Got it. How many units are in stock?"

User:  "20"
FSM:   ADD_PRODUCT_QTY → CONFIRM_ADD | "Confirm: Uji Flour 2kg at KES 150.00, 20 units. Reply yes or no."

User:  "yes"
FSM:   CONFIRM_ADD → IDLE            | "Product added: Uji Flour 2kg at KES 150.00, opening qty 20."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Biggest Lesson of Week 10: Process Separation and Database Sessions
&lt;/h3&gt;

&lt;p&gt;After completing the flow, I ran into a design question that tripped me up. When a user confirms a product, the data needs to move from Redis into PostgreSQL permanently. But here's the problem: &lt;strong&gt;Celery workers and the FastAPI web app are completely separate operating system processes&lt;/strong&gt; — often separate Docker containers in production. They cannot share memory, engines, or connection pools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk64pctfse5zmfnasbqmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk64pctfse5zmfnasbqmx.png" alt="Process separation diagram: FastAPI web app vs. Celery worker, showing separate DB engines and session management" width="799" height="589"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the FastAPI app, database sessions are managed through dependency injection — &lt;code&gt;Depends(get_db)&lt;/code&gt; yields a request-scoped session tied to the HTTP request lifecycle. Celery tasks are triggered by messages off the Redis queue, not HTTP requests. You can't use &lt;code&gt;Depends(get_db)&lt;/code&gt; inside a worker.&lt;/p&gt;

&lt;p&gt;The solution was a dedicated &lt;strong&gt;async context manager&lt;/strong&gt; for background tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;async_session_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;class_&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AsyncSession&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_worker_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncGenerator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_session_factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside any Celery task, wrapping execution in &lt;code&gt;async with get_worker_db() as db:&lt;/code&gt; gives a task-scoped transactional session. This wins on three fronts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No idle DB locks:&lt;/strong&gt; State transitions that don't persist (like &lt;code&gt;NAME → PRICE&lt;/code&gt;) open and close the session instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic commits:&lt;/strong&gt; The context manager automatically commits on clean exit, making product creation atomic at the &lt;code&gt;CONFIRM_ADD&lt;/code&gt; step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic rollback:&lt;/strong&gt; If PostgreSQL raises a constraint error or connection error mid-task, the rollback is guaranteed — no corrupted state.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Week 11 — RECORD_SALE and CHECK_STOCK Flows: Fuzzy Product Name Matching
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;ADD_PRODUCT&lt;/code&gt; working end-to-end, Week 11 expanded the FSM to cover the two flows shopkeepers would use daily: recording a sale and checking stock.&lt;/p&gt;

&lt;p&gt;The wrinkle that made this interesting: &lt;strong&gt;users don't type exact product names.&lt;/strong&gt; They type &lt;code&gt;"milk"&lt;/code&gt;, &lt;code&gt;"mllk"&lt;/code&gt;, &lt;code&gt;"Fresh Milk"&lt;/code&gt;. The system needs to figure out what they actually meant.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Fuzzy Matching?
&lt;/h3&gt;

&lt;p&gt;Fuzzy matching is a technique for finding strings that are &lt;em&gt;approximately&lt;/em&gt; equal rather than exactly equal. A standard database index treats a one-letter typo as a completely different word. Fuzzy matching provides a mathematical way to say: &lt;em&gt;"These two strings aren't identical, but they're a 90% match."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's the same technology behind Google's &lt;em&gt;"Did you mean...?"&lt;/em&gt; feature and autocorrect.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Core Metric: Levenshtein Distance
&lt;/h4&gt;

&lt;p&gt;The most common distance measure is &lt;strong&gt;Levenshtein Distance&lt;/strong&gt; — the minimum number of single-character operations (insertion, deletion, substitution) needed to transform one word into another.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cat&lt;/code&gt; → &lt;code&gt;cats&lt;/code&gt;: distance 1 (one insertion)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bread&lt;/code&gt; → &lt;code&gt;bred&lt;/code&gt;: distance 1 (one deletion)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bred&lt;/code&gt; → &lt;code&gt;brad&lt;/code&gt;: distance 1 (one substitution)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower distance = closer match.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Performance Problem
&lt;/h4&gt;

&lt;p&gt;Calculating Levenshtein distance against a full product table is expensive. On a table with 1,000 products, a naive query calculates the distance for every single row. A standard B-Tree index can't help here — it only understands exact alphabetical ordering. At scale, this would spike the database CPU immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: PostgreSQL pg_trgm and GIN Indexes
&lt;/h3&gt;

&lt;p&gt;Rather than introducing a separate fuzzy search library, I used what was already in the stack: &lt;strong&gt;PostgreSQL's &lt;code&gt;pg_trgm&lt;/code&gt; extension&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of full-string comparison, &lt;code&gt;pg_trgm&lt;/code&gt; breaks strings into overlapping &lt;strong&gt;trigrams&lt;/strong&gt; — groups of three consecutive characters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"bread"&lt;/code&gt; → &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, &lt;code&gt;bre&lt;/code&gt;, &lt;code&gt;rea&lt;/code&gt;, &lt;code&gt;ead&lt;/code&gt;, &lt;code&gt;ad&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"bred"&lt;/code&gt; → &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, &lt;code&gt;bre&lt;/code&gt;, &lt;code&gt;red&lt;/code&gt;, &lt;code&gt;ed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The database measures similarity by counting &lt;em&gt;shared trigrams&lt;/em&gt;. A &lt;strong&gt;GIN (Generalized Inverted Index)&lt;/strong&gt; indexes these chunks, so PostgreSQL can skip 99% of the table and surface the closest matches in milliseconds.&lt;/p&gt;

&lt;p&gt;Why not a dedicated search library or service? Because the product catalogue for a small shop is not a Google-scale problem. Everything already lives in PostgreSQL. Adding &lt;code&gt;pg_trgm&lt;/code&gt; means zero new infrastructure, and the queries are fast enough for the domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confidence-Based Routing
&lt;/h3&gt;

&lt;p&gt;When a user types a product name, the system returns similarity-scored matches and routes them based on confidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 0.8&lt;/td&gt;
&lt;td&gt;Auto-select product, proceed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3 – 0.8&lt;/td&gt;
&lt;td&gt;Disambiguation: list up to 3 options — &lt;em&gt;"Did you mean: 1. Fresh Milk 500ml  2. Mala Milk 1L? Reply 1 or 2"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 0.3&lt;/td&gt;
&lt;td&gt;Re-prompt user, no match found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This required one addition I hadn't planned for in the original FSM spec: a new &lt;code&gt;RECORD_SALE_PRODUCT_SELECTION&lt;/code&gt; state. When multiple candidates are returned, the FSM needs to pause and wait for the user's selection — it can't just advance to quantity collection. The candidates are stored in the session context, and the user's numeric response (&lt;code&gt;"1"&lt;/code&gt;, &lt;code&gt;"2"&lt;/code&gt;) is mapped to the correct product.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jhpntl8vq7u37l0562y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jhpntl8vq7u37l0562y.png" alt="Fuzzy match confidence routing diagram: exact match → auto-select, ambiguous → disambiguation state, no match → re-prompt" width="800" height="1053"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The RECORD_SALE Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE → RECORD_SALE_PRODUCT → [RECORD_SALE_PRODUCT_SELECTION] → RECORD_SALE_QTY → CONFIRM_SALE → IDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key business rules built into the flow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stock validation guard.&lt;/strong&gt; Before transitioning to &lt;code&gt;CONFIRM_SALE&lt;/code&gt;, the system verifies &lt;code&gt;requested_qty &amp;lt;= available_stock&lt;/code&gt;. If insufficient stock exists, the FSM holds at &lt;code&gt;RECORD_SALE_QTY&lt;/code&gt; and warns the user — it never lets the sale proceed into confirmation with stock it doesn't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic transaction execution.&lt;/strong&gt; On &lt;code&gt;"yes"&lt;/code&gt; at &lt;code&gt;CONFIRM_SALE&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Insert into the &lt;code&gt;sales&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;Atomically decrement &lt;code&gt;inventory.quantity&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check if remaining stock hits the low-stock threshold and queue a notification task if so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The CHECK_STOCK Flow
&lt;/h3&gt;

&lt;p&gt;A lightweight 1–2 turn flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE → CHECK_STOCK_QUERY → IDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fuzzy lookup → return current quantity and unit price → reset to IDLE. Simple, but uses the same trigram matching infrastructure as the sale flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 12 — WhatsApp Webhook: HMAC Verification, Payload Schema, and Deduplication
&lt;/h2&gt;

&lt;p&gt;The final week of Month 3 was about building the &lt;strong&gt;front door&lt;/strong&gt; — the point where the outside world starts talking to SokoFlow.&lt;/p&gt;

&lt;p&gt;Until now, messages entered through the internal chat simulator. Week 12 replaced the simulator with a production-shaped webhook that processes requests the way the real WhatsApp Business Platform would send them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubeckv50cyq9lt90vk0u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubeckv50cyq9lt90vk0u.png" alt="Webhook pipeline flow: WhatsApp HTTP POST → FastAPI endpoint → validate payload → verify HMAC → deduplicate → enqueue Celery task → return 200 OK" width="800" height="1137"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core architectural principle for this week, pulled directly from the Developer Manifesto:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"The Webhook Endpoint Is Dumb. The Worker Is Smart."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The endpoint validates, deduplicates, enqueues, and responds. It does not touch the database. It does not make business decisions. That's the entire architectural thesis of Week 12.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding HMAC: Authentication and Integrity
&lt;/h3&gt;

&lt;p&gt;A message arriving at the server doesn't prove it came from a trusted sender. A webhook endpoint is a publicly reachable URL — anyone who knows it can send requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HMAC (Hash-based Message Authentication Code)&lt;/strong&gt; solves this with a shared secret known only to the sender and receiver:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;sender&lt;/strong&gt; combines the raw message body and the secret to produce a signature, which travels with the request.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;receiver&lt;/strong&gt; independently computes the expected signature from the same raw body and the same secret, then compares the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the signatures match, two things are proven:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; — the request was created by someone who possesses the shared secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt; — the message body was not modified after it was signed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;HMAC does &lt;em&gt;not&lt;/em&gt; prove freshness. Replay protection requires additional mechanisms — timestamps and event IDs — layered on top.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;HMAC is a mathematical secret handshake between two systems.&lt;/strong&gt; The secret stays private. Only the resulting signature travels over the wire.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the Raw Bytes Matter
&lt;/h3&gt;

&lt;p&gt;One implementation detail with real consequences: &lt;strong&gt;signature verification must happen against the raw request body bytes — before any JSON parsing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parsing the JSON and then re-serializing it can silently alter whitespace, key ordering, or escape sequences. The re-serialized body produces a different HMAC digest even though the data appears identical. Signature mismatch → 401 → dropped message.&lt;/p&gt;

&lt;p&gt;The verification step uses &lt;code&gt;hmac.compare_digest()&lt;/code&gt; rather than a plain string comparison. This provides constant-time comparison, which eliminates timing-based side-channel attacks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No valid signature → no entry. No exceptions.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the WhatsApp Webhook Payload
&lt;/h3&gt;

&lt;p&gt;The WhatsApp Business Platform sends a specific JSON envelope for inbound messages. The key components SokoFlow cares about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messaging_product&lt;/code&gt;&lt;/strong&gt;: Always &lt;code&gt;"whatsapp"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/strong&gt;: Business identification — &lt;code&gt;display_phone_number&lt;/code&gt; and &lt;code&gt;phone_number_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;contacts&lt;/code&gt;&lt;/strong&gt;: Sender profile — &lt;code&gt;profile.name&lt;/code&gt; and &lt;code&gt;wa_id&lt;/code&gt; (the sender's phone number).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/strong&gt;: The actual inbound message content — &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;text.body&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statuses&lt;/code&gt;&lt;/strong&gt;: Delivery receipt tracking (sent, delivered, read, failed) — ignored by SokoFlow's webhook since it only processes inbound messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Pydantic contract models only what SokoFlow actually needs — not the entire WhatsApp universe. The goal was clean: feed the simulator payload through Pydantic and reliably extract &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;message_id&lt;/code&gt;, &lt;code&gt;message_text&lt;/code&gt;, and &lt;code&gt;message_type&lt;/code&gt; without the webhook knowing anything about FSM logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis Deduplication at the Webhook Layer
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k4yefnfri5ydmuflzhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k4yefnfri5ydmuflzhy.png" alt="Deduplication flow: incoming message → check Redis dedup set → exists? drop with 200 OK : process → add to dedup set with 60s TTL → enqueue" width="800" height="857"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;External networks are unreliable. The same webhook can arrive twice under normal operating conditions — retransmissions, network jitter, WhatsApp's own retry logic. The system must not record a sale twice because the same event showed up twice.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;is_duplicate&lt;/code&gt; mechanism from Week 9 handles this at the webhook layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# SET key "1" only if it does NOT already exist (NX), with a 60-second TTL (EX)
&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dedup:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Duplicate &lt;code&gt;message_id&lt;/code&gt; → return &lt;code&gt;200 OK&lt;/code&gt; with &lt;code&gt;status: ignored&lt;/code&gt; — no FSM processing, no Celery task dispatch.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;200 OK&lt;/code&gt; matters: returning an error code would signal to WhatsApp that the delivery failed, causing it to retry — which is exactly the cycle we're trying to prevent.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Complete Webhook Pipeline
&lt;/h3&gt;

&lt;p&gt;By the end of Week 12, a valid inbound message travels through this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;FastAPI receives the HTTP POST.&lt;/li&gt;
&lt;li&gt;Pydantic validates the payload shape. Malformed → 422.&lt;/li&gt;
&lt;li&gt;HMAC-SHA256 signature is verified against the raw body. Invalid → 401.&lt;/li&gt;
&lt;li&gt;Redis checks the &lt;code&gt;message_id&lt;/code&gt; for duplicates. Duplicate → 200 OK, dropped.&lt;/li&gt;
&lt;li&gt;The message is enqueued into Celery's &lt;code&gt;conversation_tasks&lt;/code&gt; queue.&lt;/li&gt;
&lt;li&gt;FastAPI returns 200 OK immediately.&lt;/li&gt;
&lt;li&gt;The Celery worker picks up the task, fetches or initialises the Redis session, runs the FSM, persists to PostgreSQL if needed, and sends the response back through the chat simulator loop.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Progress Since Month 1
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;th&gt;Month 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core business logic&lt;/td&gt;
&lt;td&gt;Automated workflows&lt;/td&gt;
&lt;td&gt;Conversational engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local development&lt;/td&gt;
&lt;td&gt;Containerized development&lt;/td&gt;
&lt;td&gt;Stateful conversations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual test runs&lt;/td&gt;
&lt;td&gt;CI pipeline&lt;/td&gt;
&lt;td&gt;Redis-backed sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single synchronous process&lt;/td&gt;
&lt;td&gt;Background workers via Celery&lt;/td&gt;
&lt;td&gt;Finite state machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on my machine&lt;/td&gt;
&lt;td&gt;Runs in the cloud&lt;/td&gt;
&lt;td&gt;Simulated WhatsApp integration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Thoughts and What's Next
&lt;/h2&gt;

&lt;p&gt;Month 3 is done. That's 12 weeks of building and shipping SokoFlow, and for the first time it actually &lt;em&gt;feels&lt;/em&gt; like a conversational system rather than just a backend with ambitions.&lt;/p&gt;

&lt;p&gt;The interesting engineering challenge this month wasn't the code — it was the &lt;strong&gt;design&lt;/strong&gt;. How do you impose deterministic structure on something as unpredictable as a human conversation? The answer turned out to be: you don't fight the unpredictability. You contain it. State machines give each message exactly one valid set of responses depending on where the conversation is. Redis gives the conversation memory. Lua scripting makes that memory safe under concurrency. Pydantic keeps every input honest before it touches business logic.&lt;/p&gt;

&lt;p&gt;And the reason I didn't use an LLM? That's a full answer worth its own section, and it's coming in the Month 4 wrap-up. The short version: an LLM would have been easier to build, but harder to operate, debug, and trust for a system that moves real money and inventory for real shop owners.&lt;/p&gt;

&lt;p&gt;Month 4 moves into &lt;strong&gt;async report generation, chaos testing, Swahili language support, and final integration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The system can now have a conversation. Next month, I want to see how well it holds up when things go wrong.&lt;/p&gt;

&lt;p&gt;Stay locked in.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Thinking Like a Platform Engineer: Celery, Docker, CI/CD, and Getting SokoFlow Into the Cloud</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:50:18 +0000</pubDate>
      <link>https://dev.to/paulmurithi/thinking-like-a-platform-engineer-celery-docker-cicd-and-getting-sokoflow-into-the-cloud-109l</link>
      <guid>https://dev.to/paulmurithi/thinking-like-a-platform-engineer-celery-docker-cicd-and-getting-sokoflow-into-the-cloud-109l</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 2 of 4
&lt;/h3&gt;

&lt;p&gt;Welcome back to another SokoFlow build log. If you're new here — I'm an IT student running a structured, project-based learning plan to grow into a production-grade backend engineer. Last semester I built &lt;a href="https://simpesa-docs.vercel.app/" rel="noopener noreferrer"&gt;&lt;strong&gt;SimPesa&lt;/strong&gt;&lt;/a&gt;, a local-first STK Push simulator for testing M-Pesa Daraja payment workflows without touching a live sandbox. This semester, the theme is &lt;em&gt;from controlled environments to the messy real world&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SokoFlow&lt;/strong&gt; is my flagship project for this semester: a conversational ERP for small Kenyan shopkeepers that lets them track inventory and record sales entirely through chat — no app to download, no onboarding session, just natural language over WhatsApp. I covered the architecture and core business logic in the &lt;a href="https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f"&gt;Month 1 build log&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Month 2 wasn't about adding features. It was about making everything built in Month 1 deployable, scalable, and production-ready — so that every subsequent feature ships into an environment that actually mirrors the real world. That meant Celery, Docker, CI/CD, and a live staging deployment. Let's get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where We Left Off
&lt;/h2&gt;

&lt;p&gt;Month 1 ended with a solid core: CRUD operations for the key models, business rules enforced through a strict TDD cycle, and a combined test suite of 40+ tests at 92% coverage. The foundation was sound — but it only ran on my machine.&lt;/p&gt;

&lt;p&gt;Month 2's goals were concrete and measurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Celery processes a test job, with worker logs confirming execution.&lt;/li&gt;
&lt;li&gt;A green badge on the main branch; pull requests blocked if coverage drops below 85%.&lt;/li&gt;
&lt;li&gt;A production Dockerfile with build logs showing cache hits on unchanged layers.&lt;/li&gt;
&lt;li&gt;A CD pipeline that deploys to staging on merge to main, with both the Swagger UI and &lt;code&gt;/health&lt;/code&gt; endpoint publicly accessible.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Week 5 — Celery Integration: Broker Config, Task Definition, Worker Startup
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I had to build the right mental model. This week revolved entirely around one idea: &lt;strong&gt;background jobs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In Month 1, I built CRUD for sales. Now imagine a shopkeeper sends the system a message at the end of the day: &lt;em&gt;"Generate a daily sales report."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The naive approach is to handle that inside the normal HTTP request-response cycle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffj9pcsioe14paw2pads8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffj9pcsioe14paw2pads8.png" alt="Synchronous Report Generation" width="800" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problems are immediate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API is blocked for the entire duration.&lt;/li&gt;
&lt;li&gt;The request is likely to time out.&lt;/li&gt;
&lt;li&gt;The server can't handle any other users while it waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The better pattern is to offload the heavy work to a &lt;strong&gt;background task&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrdznd30pqyem67aghna.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrdznd30pqyem67aghna.png" alt="Background Job Report Generation" width="800" height="746"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The user gets an immediate response. The hard work happens asynchronously. The API stays free to serve other requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Each Component Fits
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Producer&lt;/strong&gt; (the API)&lt;/td&gt;
&lt;td&gt;Receives the job, adds it to the queue, responds immediately. It does not execute the work.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Broker&lt;/strong&gt; (Redis / RabbitMQ)&lt;/td&gt;
&lt;td&gt;The mailroom. Stores tasks until a worker picks them up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The ordered line of waiting jobs inside the broker. First-in, first-out (with optional priority support).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Worker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The process that actually executes jobs. You can scale workers independently to handle more load.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Celery&lt;/strong&gt; is not the broker — it's the framework that ties everything together. It gives you a client (inside the API) to publish jobs, and a worker process to consume and execute them. The broker — Redis in SokoFlow's case — is the transport layer that carries messages between the two.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model That Cleared My Confusion
&lt;/h3&gt;

&lt;p&gt;Coming from SimPesa, I had worked extensively with BullMQ. The name similarity between BullMQ and RabbitMQ made me mentally group them as peers — which led to real confusion when setting Celery up.&lt;/p&gt;

&lt;p&gt;The hierarchy that finally cleared it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application Layer      → Your business logic (generate_report)
Task Framework Layer   → BullMQ (Node.js) | Celery (Python)      ← peers
Messaging/Storage      → RabbitMQ | Redis | Amazon SQS           ← peers
Infrastructure         → TCP | Disk | Memory | Network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BullMQ and Celery sit at the same layer. RabbitMQ and Redis sit at the same layer. They are not interchangeable across layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;Offloading slow operations to background workers keeps the API fast and improves scalability — but it's not free. It introduces real complexity in error handling, state management, and debugging, because execution now happens entirely outside the main request flow. Design for that from the start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 6 — GitHub Actions: Setting Up the CI Pipeline
&lt;/h2&gt;

&lt;p&gt;With Celery integrated, Week 6 was about automation. Specifically, making sure the test suite runs on every push and that a failing test physically blocks deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why CI Exists: The Problem It Solved
&lt;/h3&gt;

&lt;p&gt;To understand why CI pipelines matter, it helps to understand what software development looked like before them.&lt;/p&gt;

&lt;p&gt;Imagine a team of ten developers, each working on an isolated feature branch for several weeks. When everyone finally tries to merge their work at once — different assumptions, different dependencies, overlapping changes — the result is what engineers call &lt;strong&gt;Integration Hell&lt;/strong&gt;: a codebase that can barely compile, let alone pass tests.&lt;/p&gt;

&lt;p&gt;The breakthrough insight was deceptively simple: &lt;strong&gt;merge frequently&lt;/strong&gt;. If you merge three months of work at once, you inherit a thousand conflicts. Finding the root cause is nearly impossible. If you merge three hours of work, you get one or two conflicts. Root cause analysis takes minutes.&lt;/p&gt;

&lt;p&gt;But merging daily created a new bottleneck — human fatigue. Every merge meant someone had to manually compile the code, install dependencies, and run thousands of tests. Steps got skipped. Mistakes got missed.&lt;/p&gt;

&lt;p&gt;The solution was to automate the checklist entirely: &lt;strong&gt;CI pipelines&lt;/strong&gt;. Every push triggers an isolated server to compile the code, run the tests, and return a clear pass or fail signal. Jenkins, GitHub Actions, GitLab CI — they all do this, just with different configuration syntax.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Database dependencies in CI.&lt;/strong&gt; Locally, the test suite runs against a Postgres and Redis instance I already have configured. The CI runner starts with a completely clean machine. How does it get those?&lt;/p&gt;

&lt;p&gt;The modern answer is &lt;strong&gt;service containers&lt;/strong&gt;. When the CI job starts, the runner spins up lightweight Docker containers for Postgres and Redis alongside the main test container, connects them on a private local network, and exposes them via hostnames like &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;postgres&lt;/code&gt;. The test code connects to them exactly the same way it does locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credentials and security.&lt;/strong&gt; Having database credentials hardcoded in a YAML file sounds alarming, but test database credentials carry essentially zero risk. The containers live on a private, ephemeral network that's unreachable from the public internet, and the database is permanently destroyed the moment the tests finish.&lt;/p&gt;

&lt;p&gt;Sensitive credentials — production deployment keys, third-party API keys — are a different matter entirely. Those go into the platform's encrypted secrets store (GitHub's "Secrets" settings panel), and the pipeline injects them into the runner's memory as environment variables at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model: GitHub Actions Runner = A Rented Computer
&lt;/h3&gt;

&lt;p&gt;The most useful reframe for understanding GitHub Actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;On Your Laptop&lt;/th&gt;
&lt;th&gt;On the GitHub Runner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;You open your laptop&lt;/td&gt;
&lt;td&gt;GitHub spins up a clean virtual machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;You write code in VS Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;actions/checkout&lt;/code&gt; downloads your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;You install Python and uv&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setup-python&lt;/code&gt; and &lt;code&gt;setup-uv&lt;/code&gt; install them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;You start a database container&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;services&lt;/code&gt; block starts one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;You type &lt;code&gt;uv run pytest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The runner runs &lt;code&gt;uv run pytest&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup&lt;/td&gt;
&lt;td&gt;You shut your laptop&lt;/td&gt;
&lt;td&gt;GitHub wipes the disk and destroys the VM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only real difference: your laptop persists its state. The runner is &lt;strong&gt;ephemeral&lt;/strong&gt; — it starts completely clean, does exactly what you scripted, and is destroyed the moment it finishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;A GitHub Actions runner is not abstract magic. It is a regular computer in a data center, running the same commands you would run manually in a terminal. Once that clicked, everything about configuring CI became far more intuitive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 7 — Docker Image Optimization
&lt;/h2&gt;

&lt;p&gt;With CI in place, Week 7 was about containerization. My targets: build time under 90 seconds and a final image size under 200MB. Given the number of services in SokoFlow's stack, I was skeptical.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem With Unoptimized Images
&lt;/h3&gt;

&lt;p&gt;The typical first Dockerfile — pull a full OS, install every tool, copy all the code, run it — produces images that are 1–2 GB. That creates real operational problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;High infrastructure costs.&lt;/strong&gt; A team deploying 20 times a day and pushing a 2 GB image each time wastes enormous bandwidth and storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow deployments.&lt;/strong&gt; Auto-scaling events that require pulling a 2 GB image are measurably slower than pulling a 100 MB one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expanded attack surface.&lt;/strong&gt; Bundling compilers, package managers, and text editors into a production image gives an attacker more tools to work with if they ever get in.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Solution 1: Multi-Stage Builds
&lt;/h3&gt;

&lt;p&gt;A multi-stage build is like a professional kitchen. You use the full counter space — heavy tools, cutting boards, prep mess — to build the dish. But when it's time to serve, only the final plate goes out. The kitchen stays in the kitchen.&lt;/p&gt;

&lt;p&gt;In Dockerfile terms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build stage:&lt;/strong&gt; Start with a heavy base image that includes compilers and package managers. Install all dependencies, compile what needs compiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production stage:&lt;/strong&gt; Start fresh with a minimal base image (Alpine Linux is around 5 MB). Copy only the compiled application artifacts from the build stage. Discard everything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: a final image that might be 50–80 MB instead of 1 GB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solution 2: Respecting the Layer Cache
&lt;/h3&gt;

&lt;p&gt;Every instruction in a Dockerfile creates a new &lt;strong&gt;immutable layer&lt;/strong&gt;. Docker caches these layers and reuses them if their inputs haven't changed. The key implication: &lt;strong&gt;layer order determines cache efficiency&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Layer 1 — Base image (rarely changes)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-alpine&lt;/span&gt;

&lt;span class="c"&gt;# Layer 2 — Dependencies (changes when requirements.txt changes)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Layer 3 — Application code (changes constantly)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only change application code, Docker rebuilds only Layer 3. Layers 1 and 2 are pulled straight from cache. Copy your code before your dependencies and you forfeit that optimization entirely — every code change forces a full dependency reinstall.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Chaining &lt;code&gt;RUN&lt;/code&gt; commands.&lt;/strong&gt; I noticed the recommended pattern for &lt;code&gt;apt-get&lt;/code&gt; installs chains everything into a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Three separate layers — the cleanup layer doesn't actually remove the cached package index&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; build-essential
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# One layer — download, install, and cleanup happen before Docker takes the snapshot&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; build-essential libpq-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtlety: deleting files in a later layer doesn't remove them from earlier layers — it just hides them. The data is still baked into the image history. Chaining with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; ensures everything happens inside a single container state before Docker freezes it into a layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a Python app need C build tools?&lt;/strong&gt; Adding &lt;code&gt;build-essential&lt;/code&gt; and &lt;code&gt;libpq-dev&lt;/code&gt; to a Python Dockerfile initially confused me. I was writing Python — why did I need a C compiler?&lt;/p&gt;

&lt;p&gt;The answer is in how &lt;code&gt;psycopg2&lt;/code&gt; works. Python is a high-level, dynamically typed language — fast to write, but comparatively slow to execute. PostgreSQL is written in C and expects high-speed communication. &lt;code&gt;psycopg2&lt;/code&gt; bridges that gap by wrapping a C extension that handles network sockets, memory management, and binary data streams directly. When Python calls &lt;code&gt;psycopg2.connect()&lt;/code&gt;, it hands the heavy lifting to that underlying C layer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;build-essential&lt;/code&gt; provides &lt;code&gt;gcc&lt;/code&gt;, the C compiler needed to build that extension.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;libpq-dev&lt;/code&gt; provides the PostgreSQL header files — the "dictionary" the C compiler needs to understand how to communicate with Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Python Wheels.&lt;/strong&gt; Historically, &lt;code&gt;pip install psycopg2&lt;/code&gt; downloaded raw C source and compiled it locally — slow builds and cryptic errors if the build tools weren't present. Python's solution is &lt;strong&gt;Wheels&lt;/strong&gt; (&lt;code&gt;.whl&lt;/code&gt; files): pre-compiled binary packages built by library maintainers for common operating systems and uploaded to PyPI. When a matching Wheel exists, &lt;code&gt;pip install&lt;/code&gt; just downloads and unpacks it. No C compilation required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;Docker is not magic. It is mostly Linux. The Dockerfile is a sequence of Linux commands executed inside a container environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is literally running &lt;code&gt;apt-get update&lt;/code&gt; inside the image. Once I stopped thinking of Docker as a separate abstraction and started reading it as a shell script with layers, the mental overhead dropped significantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 8 — Deployment: Shipping to Staging
&lt;/h2&gt;

&lt;p&gt;Week 8 was the capstone. The goal: a CD pipeline that deploys to staging automatically on every successful merge to main, with the Swagger UI and &lt;code&gt;/health&lt;/code&gt; endpoint publicly accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting CI to CD
&lt;/h3&gt;

&lt;p&gt;The interesting design question here wasn't the deployment itself — it was the &lt;em&gt;sequencing&lt;/em&gt;. You only want to deploy when CI passes. A failing test suite means questionable code, and questionable code should never deploy automatically.&lt;/p&gt;

&lt;p&gt;The simplest pattern is a single workflow file using the &lt;code&gt;needs&lt;/code&gt; keyword to create a strict job dependency — Job B won't start until Job A finishes with a green checkmark.&lt;/p&gt;

&lt;p&gt;I opted for two separate files (&lt;code&gt;ci.yml&lt;/code&gt; and &lt;code&gt;deploy.yml&lt;/code&gt;) because separate files communicate intent more clearly and are easier to manage independently. The tradeoff is that the &lt;code&gt;needs&lt;/code&gt; keyword doesn't work across files — they don't share context. The solution is &lt;code&gt;workflow_run&lt;/code&gt;, which lets the deployment workflow listen for the CI workflow to complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.conclusion == 'success' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the deployment job only wakes up when the CI workflow finishes, and only proceeds if it finished successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Database driver mismatch.&lt;/strong&gt; Railway provided a standard PostgreSQL connection URL, but SokoFlow's SQLAlchemy engine is configured for async operation using &lt;code&gt;asyncpg&lt;/code&gt;. A plain &lt;code&gt;postgresql://&lt;/code&gt; URL defaults to the synchronous &lt;code&gt;psycopg2&lt;/code&gt; driver, which fails immediately in an async context. The fix is to normalize the driver on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drivername&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+psycopg2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivername&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+asyncpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Full Deployment Journey
&lt;/h3&gt;

&lt;p&gt;Here's how a single &lt;code&gt;git push&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt; travels from a local machine to a live staging environment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmwe9cqd3dv6ckojsha8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmwe9cqd3dv6ckojsha8.png" alt="Github Actions Railway Pipeline" width="800" height="812"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CI pipeline&lt;/strong&gt; — GitHub Actions spins up an isolated runner, installs dependencies, and runs the full test suite. If anything fails, the pipeline stops here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CD pipeline&lt;/strong&gt; — On CI success, the deployment workflow triggers and connects to Railway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build phase&lt;/strong&gt; — Railway reads the Dockerfile and builds the production image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-deploy phase&lt;/strong&gt; — Railway runs &lt;code&gt;alembic upgrade head&lt;/code&gt; in a temporary container, applying any pending database migrations before the new application code goes live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live deploy&lt;/strong&gt; — Railway starts the new FastAPI containers and performs a health check. Once they respond successfully, traffic is switched from the old containers to the new ones with minimal downtime.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;The cloud is not magic either. Before Week 8, SokoFlow looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FastAPI → localhost → Postgres, Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Week 8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FastAPI → Environment Variables → Railway PostgreSQL, Railway Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application code is identical. It only knows connection strings. Environment variables replace &lt;code&gt;.env&lt;/code&gt; in production — same variable names, different values, and the application has no idea where they came from.&lt;/p&gt;

&lt;p&gt;And staging is not "production lite." It is a production-identical environment used to validate deployment mechanics, infrastructure configuration, and integration behavior before anything touches real users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Month 1 → Month 2: What Changed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core business logic&lt;/td&gt;
&lt;td&gt;Automated deployment workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local development only&lt;/td&gt;
&lt;td&gt;Containerized and reproducible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual test runs&lt;/td&gt;
&lt;td&gt;CI pipeline with enforced coverage gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single synchronous process&lt;/td&gt;
&lt;td&gt;Background workers via Celery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on my machine&lt;/td&gt;
&lt;td&gt;Runs in the cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Looking Ahead to Month 3: Conversation Engine and Webhook Simulation
&lt;/h2&gt;

&lt;p&gt;Month 3 is the feature I've been looking forward to most since this project started — and probably the hardest one.&lt;/p&gt;

&lt;p&gt;A WhatsApp message looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"sold 3 milks"
"Add uji 24 pcs @ 85"
"How much stock for soda?"
"Today's report"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the system needs structured actions like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-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;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RECORD_SALE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Milk 500ml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&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;The core challenge: how do you turn unpredictable human language into deterministic system commands — without the whole thing collapsing into spaghetti at scale?&lt;/p&gt;

&lt;p&gt;SokoFlow's answer is &lt;strong&gt;Finite State Machines (FSMs) + controlled intent parsing + conversational context&lt;/strong&gt;. The easy path would be to drop an LLM into the middle of this and let it handle everything. I didn't take that path — and Month 3's build log explains exactly why.&lt;/p&gt;

&lt;p&gt;Stay tuned.&lt;/p&gt;

</description>
      <category>cloudcomputing</category>
      <category>learning</category>
      <category>docker</category>
      <category>backenddevelopment</category>
    </item>
    <item>
      <title>Shifting Left: How TDD Became the Foundation of SokoFlow's Core Engine</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Tue, 30 Jun 2026 12:35:46 +0000</pubDate>
      <link>https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f</link>
      <guid>https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 1 of 4
&lt;/h3&gt;

&lt;p&gt;Last semester I set out on a new strategic plan to level up my software development skills through deliberate, project-based learning. That work produced one of the most ambitious things I've built so far: &lt;strong&gt;Sim-Pesa&lt;/strong&gt;, a local-first transactional appliance that lets developers working in the M-Pesa ecosystem test and simulate STK Push workflows entirely on their own machines, without depending on the Daraja sandbox. I documented that build in 16 weekly posts, which you can find &lt;a href="https://hashnode.com/@paul-murithi" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This semester, the focus shifts — from fintech foundations to cloud-native integration and real-world systems. The flagship project is &lt;strong&gt;SokoFlow&lt;/strong&gt;, a conversational ERP for small Kenyan shopkeepers to track inventory and record sales entirely through WhatsApp chat. No app to download, no training session required — just natural language.&lt;/p&gt;

&lt;p&gt;Where Sim-Pesa lived in a controlled, predictable transactional world, SokoFlow steps into the mess of cloud-native reality: third-party API failures, webhook signature verification, the statelessness of HTTP, and container orchestration. The target audience shifts too — Kenyan SMEs operating on infrastructure that is often unreliable by design, not by exception.&lt;/p&gt;

&lt;p&gt;It's an ambitious project, but the goal was always to learn as much as possible from it. With the plan in place, I got to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Vision of a Headless ERP
&lt;/h2&gt;

&lt;p&gt;The first real question I had to answer before writing a line of code: what does "headless" actually mean?&lt;/p&gt;

&lt;p&gt;Headless architecture decouples the frontend — the "head," or user interface — from the backend, the "body" that holds the data and business logic. A conventional ERP bundles both: backend plus a dashboard or UI on top. A headless ERP, by contrast, is just the engine. The brain. There's no built-in screen.&lt;/p&gt;

&lt;p&gt;So how do users interact with a system that has no interface of its own? SokoFlow doesn't actually care. It could be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WhatsApp&lt;/li&gt;
&lt;li&gt;SMS&lt;/li&gt;
&lt;li&gt;A web app&lt;/li&gt;
&lt;li&gt;A mobile app&lt;/li&gt;
&lt;li&gt;A voice assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this case, the "frontend" happens to be a WhatsApp conversation. Instead of clicking "Add Product," the shopkeeper just texts:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Added 5 packets of milk"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the backend processes that message the way an ERP would process a structured command.&lt;/p&gt;

&lt;p&gt;In a sense, most modern systems are already headless-ish. Any architecture where the backend exposes an API and the frontend simply consumes JSON over HTTP is practically headless by default — if you've built something like that before, you were already doing this without naming it.&lt;/p&gt;

&lt;p&gt;SokoFlow just makes the principle explicit. The backend is built on the assumption that it may &lt;em&gt;never&lt;/em&gt; have a traditional UI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Normal web app&lt;/th&gt;
&lt;th&gt;SokoFlow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend exists mainly to serve a website or app&lt;/td&gt;
&lt;td&gt;Backend &lt;strong&gt;is&lt;/strong&gt; the product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend is the product&lt;/td&gt;
&lt;td&gt;WhatsApp is just one client talking to it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That framing matters because it means tomorrow I could plug in Telegram, SMS, a voice bot, a React dashboard, or USSD — without touching the core business logic.&lt;/p&gt;

&lt;p&gt;Month 1's task wasn't the exciting part on the surface: four weeks spent entirely on core business logic, with no async layers and no WhatsApp integration in sight. That was deliberate. The core is everything — if it's wrong, the conversation engine built on top of it will be wrong too. That focus is also what pulled me into one of the most talked-about (and most misunderstood) practices in software engineering: &lt;strong&gt;Test-Driven Development&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Power of TDD in Core Logic
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Test-driven_development" rel="noopener noreferrer"&gt;TDD&lt;/a&gt; is a development style where you write a failing automated test first, write just enough code to make it pass, then refactor both the test and the implementation — and repeat for the next piece of behavior. This was my first real experience working this way, and it initially felt backwards. Most of us default to Design → Write Code → Write Tests. TDD inverts that order entirely.&lt;/p&gt;

&lt;p&gt;Once it clicked, though, it was genuinely simple — the cycle is known as &lt;strong&gt;Red-Green-Refactor&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Red — write a failing test.&lt;/strong&gt; Define exactly what a piece of code should do before it exists. Since only the test exists, it fails by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green — write just enough code.&lt;/strong&gt; The minimum implementation required to make that test pass. Nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactor — clean it up.&lt;/strong&gt; Revisit both the test and the implementation, tighten them up, and confirm the tests still pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire playbook, repeated feature by feature. After working in this loop for a few weeks, I came around to it completely — it forces you to think through edge cases and the shape of a request-response cycle &lt;em&gt;before&lt;/em&gt; you write the implementation, not after.&lt;/p&gt;

&lt;p&gt;With that approach set, here's how the four weeks broke down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 1 — Project scaffold:&lt;/strong&gt; PostgreSQL schema, Alembic migrations, FastAPI skeleton.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 2 — Product core:&lt;/strong&gt; 20+ unit tests covering product CRUD operations (add, update, delete, and friends).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 3 — Inventory management:&lt;/strong&gt; Inventory deduction logic tested against boundary cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 4 — Sales recording &amp;amp; daily aggregation:&lt;/strong&gt; A sales service backed by report queries that return accurate totals against test data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of the month: &lt;strong&gt;40+ tests, 92% coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Technical Hurdles
&lt;/h2&gt;

&lt;p&gt;Everything moved smoothly on the surface, but the real lessons — as always — came from what went wrong along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Wall of New Tooling
&lt;/h3&gt;

&lt;p&gt;This was my first extended stretch writing Python, which meant getting fluent in what I've started calling the "modern Python stack."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type hints.&lt;/strong&gt; In old-style Python, &lt;code&gt;def greet(name):&lt;/code&gt; tells you nothing about what &lt;code&gt;name&lt;/code&gt; actually is — a string, a list, a database object — until the code crashes at runtime. Adding type hints like &lt;code&gt;name: str&lt;/code&gt; makes the expectation explicit, both for other developers and for tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MyPy — the quality inspector.&lt;/strong&gt; Python itself ignores type hints at runtime; they're purely documentation unless something enforces them. MyPy is a static analysis tool that reads code without executing it, catching type errors that would otherwise slip through and surface only in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pydantic — the bouncer.&lt;/strong&gt; FastAPI is built on Pydantic, a data validation library. If type hints are documentation, Pydantic is enforcement: it uses those same type hints to validate that data entering the application — from users, APIs, or the database — is exactly what it claims to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fighting Async Testing and DB Drivers
&lt;/h3&gt;

&lt;p&gt;Going all-in on TDD meant spending a lot of time inside the test suite, which meant the environment had to be low-friction enough to iterate quickly. The center of that effort was a single file: &lt;code&gt;conftest.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Understanding &lt;code&gt;conftest.py&lt;/code&gt; and fixtures.&lt;/strong&gt; Before pytest's fixture system existed, test setup was a maintenance headache — every test file needed the same boilerplate (database connections, authenticated users, HTTP clients), duplicated across the suite. Any change meant updating dozens of files, and forgotten cleanup could quietly break unrelated tests. &lt;code&gt;conftest.py&lt;/code&gt; exists to centralize that shared setup in one place. Pytest discovers it automatically and makes everything inside available to every test in the directory tree, with no explicit imports required.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;fixture&lt;/strong&gt; is just a function that builds whatever a test needs, hands it over with &lt;code&gt;yield&lt;/code&gt;, and guarantees cleanup afterward — even if the test fails. That lets each test focus purely on its assertions. Pytest also lets fixtures live at different scopes, from a fresh instance per test (&lt;code&gt;function&lt;/code&gt;) to a single shared instance for the whole run (&lt;code&gt;session&lt;/code&gt;); the goal is to use the broadest scope that still keeps tests properly isolated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving from sync to async changed more than a few keywords.&lt;/strong&gt; Swapping SQLite and standard SQLAlchemy sessions for &lt;code&gt;asyncpg&lt;/code&gt;, &lt;code&gt;AsyncSession&lt;/code&gt;, and async route handlers introduces an event loop — and every async resource is tied to the loop that created it. By default, &lt;code&gt;pytest-asyncio&lt;/code&gt; spins up a fresh event loop per test, which can leave long-lived objects like the database engine bound to a loop that no longer exists. The fix was a session-scoped &lt;code&gt;event_loop&lt;/code&gt; fixture so the entire suite shares one consistent loop.&lt;/p&gt;

&lt;p&gt;That wasn't the only wrinkle. SQLAlchemy's default connection pooling — great in production — can leak state between tests and cause loop-ownership conflicts, so switching to &lt;code&gt;NullPool&lt;/code&gt; ensures every connection is opened, used, and immediately discarded. FastAPI's synchronous &lt;code&gt;TestClient&lt;/code&gt; also has to bridge sync and async code, which made loop issues more likely; switching to &lt;code&gt;httpx.AsyncClient&lt;/code&gt; kept the tests, the client, and the application running on the same event loop, resulting in a far more reliable setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Time and Timezones
&lt;/h3&gt;

&lt;p&gt;This trips up even experienced developers, and it caught up with me too — starting with not fully internalizing the difference between &lt;strong&gt;naive&lt;/strong&gt; and &lt;strong&gt;aware&lt;/strong&gt; datetimes.&lt;/p&gt;

&lt;p&gt;A naive datetime — &lt;code&gt;datetime(2026, 6, 25, 15, 0)&lt;/code&gt; — just says "3 PM." But 3 PM &lt;em&gt;where&lt;/em&gt;? London? Nairobi? There's no way to know. An aware datetime — &lt;code&gt;datetime(2026, 6, 25, 15, tzinfo=UTC)&lt;/code&gt; — says "3 PM UTC," which is complete information.&lt;/p&gt;

&lt;p&gt;Picture two servers, one in Kenya and one in New York, both calling &lt;code&gt;datetime.now()&lt;/code&gt;. The Kenya server returns 15:00; the New York server returns 08:00. Same moment, different values — which is exactly the kind of inconsistency that makes naive datetimes unsafe in production. The fix is &lt;code&gt;datetime.now(UTC)&lt;/code&gt;, so every server agrees on a single source of truth.&lt;/p&gt;

&lt;p&gt;The sharpest version of this problem showed up in the daily report logic. To answer "what did this shop sell today," the database needs the start and end of &lt;em&gt;that specific day&lt;/em&gt; — but in &lt;em&gt;which&lt;/em&gt; timezone?&lt;/p&gt;

&lt;p&gt;The shop operates in Kenya, on EAT (UTC+3) year-round. If an owner asks for the report for June 20, they mean everything between midnight and 11:59:59 PM on June 20, &lt;strong&gt;Kenya time&lt;/strong&gt;. In UTC, those boundaries are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Local (EAT)&lt;/th&gt;
&lt;th&gt;UTC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jun 20 00:00&lt;/td&gt;
&lt;td&gt;Jun 19 21:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jun 21 00:00&lt;/td&gt;
&lt;td&gt;Jun 20 21:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the correct query is &lt;code&gt;created_at &amp;gt;= 2025-06-19T21:00:00Z AND created_at &amp;lt; 2025-06-20T21:00:00Z&lt;/code&gt; — notice that a single "June 20" local day actually spans two different UTC calendar dates.&lt;/p&gt;

&lt;p&gt;My initial implementation got this wrong by treating the date naively:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UTC&lt;/th&gt;
&lt;th&gt;Kenya (UTC+3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jun 20 00:00&lt;/td&gt;
&lt;td&gt;Jun 20 03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jun 21 00:00&lt;/td&gt;
&lt;td&gt;Jun 21 03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That version was effectively collecting sales from 3 AM June 20 to 3 AM June 21 Kenya time, instead of midnight to midnight. The result: sales between midnight and 3 AM disappeared from the correct day's report, and late-night sales from the &lt;em&gt;next&lt;/em&gt; day bled into it.&lt;/p&gt;

&lt;p&gt;If a shop closes at 8 PM, this bug is invisible — almost nothing happens between midnight and 3 AM, so the totals come out right by coincidence. But the moment that assumption breaks — late opening hours, an online order at 1 AM, an overnight automated payment, an inventory sync that runs after midnight — the cracks show immediately: June 20's report comes up short, June 21's report has mysterious extra sales, and the daily totals stop matching the receipts.&lt;/p&gt;

&lt;p&gt;The rule of thumb that came out of this: when a user requests a report for a calendar day, interpret that date in the shop's local timezone, compute the local start and end of that day, convert those instants to UTC, and only then query the database (which stores everything in UTC). The underlying principle is that &lt;strong&gt;dates are a local concept; timestamps are absolute instants&lt;/strong&gt; — a "day" has to be defined in local time first, then translated to UTC for storage and querying.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Looking Ahead to Month 2: Docker, CI/CD, and Infrastructure Automation
&lt;/h2&gt;

&lt;p&gt;With Month 1 behind me and the core business logic locked down under a solid test suite, Month 2 shifts focus from a local code project to production-ready, cloud-native infrastructure. Three things are top of mind for the next four weeks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production-grade containerization.&lt;/strong&gt; Orchestrating SokoFlow's full topology with Docker — a multi-stage &lt;code&gt;docker-compose.yml&lt;/code&gt; that cleanly networks the FastAPI gateway, PostgreSQL 15, Redis 7, and a split Celery worker pool (&lt;code&gt;conversation_tasks&lt;/code&gt; and &lt;code&gt;report_tasks&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated CI/CD quality gates.&lt;/strong&gt; A strict GitHub Actions pipeline that runs the full test suite on every push and blocks pull requests if coverage drops below 85% or MyPy flags any strict type violations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment-aware configuration.&lt;/strong&gt; Hardening configuration management so that switching between development, staging, and production happens cleanly through environment variables, with no changes to application logic required.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Conclusion &amp;amp; Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Finishing Month 1 confirmed one thing for me: &lt;strong&gt;build the business core before letting any external infrastructure distract you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forcing myself into strict TDD from day one surfaced complex boundary cases early — inventory hitting exactly zero, low-stock threshold triggers — without the noise of webhooks, servers, or third-party APIs in the way. Fighting through async test fixtures and timezone mismatches wasn't fun in the moment, but resolving those foundational issues now means Month 2 starts on an airtight, predictable core that's actually ready to scale.&lt;/p&gt;

&lt;p&gt;SokoFlow has its engine. Now it's time to build the container that runs it. Stay locked in for the next build log.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>backenddevelopment</category>
      <category>buildinpublic</category>
      <category>postgressql</category>
    </item>
  </channel>
</rss>
