<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://stzifkas.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://stzifkas.github.io/" rel="alternate" type="text/html" /><updated>2026-05-07T09:32:59+00:00</updated><id>https://stzifkas.github.io/feed.xml</id><title type="html">Fire Walk With Middleware</title><subtitle>Software engineering, LLMs, and whatever I&apos;m building. By Sokratis Tzifkas.</subtitle><author><name>stzifkas</name></author><entry><title type="html">The Waiter, the Strawberry, and the Dinner Table That Broke the Machine</title><link href="https://stzifkas.github.io/reasoning/2026/05/07/waiter-strawberry-dinner-table.html" rel="alternate" type="text/html" title="The Waiter, the Strawberry, and the Dinner Table That Broke the Machine" /><published>2026-05-07T00:00:00+00:00</published><updated>2026-05-07T00:00:00+00:00</updated><id>https://stzifkas.github.io/reasoning/2026/05/07/waiter-strawberry-dinner-table</id><content type="html" xml:base="https://stzifkas.github.io/reasoning/2026/05/07/waiter-strawberry-dinner-table.html"><![CDATA[<p><img src="/assets/waiter-strawberry-dinner-table.png" alt="The Waiter, the Strawberry, and the Dinner Table That Broke the Machine" /></p>

<p>When I was a kid, my brain worked like a small hallucinating LLM.</p>

<p>Not because I was doing matrix multiplication in the kitchen. More because I was very good at pattern completion and very bad at stopping before the answer. I could feel the <em>shape</em> of the solution before I had checked whether the solution existed.</p>

<p>This is a dangerous type of intelligence. It looks fast. It sounds confident. It sometimes even works.</p>

<p>And then your father is a mathematician.</p>

<p>So, naturally, he gives you the <a href="https://en.wikipedia.org/wiki/Missing_dollar_riddle">waiter problem</a>.</p>

<p>Three people go to eat. They pay 30 euros. Later the waiter realizes the bill should have been 25. He gives back 5. But instead of returning the full amount fairly, each person gets 1 euro back and the waiter keeps 2.</p>

<p>So each person paid 9.</p>

\[3 \times 9 = 27\]

<p>The waiter kept 2.</p>

\[27 + 2 = 29\]

<p>Where is the missing euro?</p>

<p>As a kid, this felt like occult finance. A euro had entered the metaphysical banking sector. Capital had dematerialized.</p>

<p>But of course nothing is missing.</p>

<p>The mistake is not arithmetic. The mistake is <strong>modeling</strong>.</p>

<p>The 27 euros already include the waiter’s 2 euros:</p>

\[27 = 25 + 2\]

<p>The correct ledger is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>30 originally paid
= 25 actual bill
+ 2 kept by waiter
+ 3 returned to customers
</code></pre></div></div>

<p>The wrong version adds the waiter’s 2 euros to a quantity that already contains it.</p>

<p>That is the entire trick. You are not failing at multiplication. You are failing to assign the numbers to the right semantic roles.</p>

<p>And this is where the LLM comparison becomes interesting.</p>

<p>Because LLMs are not “bad at math” in the simple sense. They can recite Ramsey’s theorem. They can explain graph coloring. They can write Python to count letters. They can define a ledger, a state machine, a constraint satisfaction problem, a bipartite graph, a clique, a complement graph.</p>

<p>They know the words.</p>

<p>The failure happens when the problem does not announce which representation it needs.</p>

<p>A model can possess the concept and still not activate it.</p>

<p>That distinction matters.</p>

<hr />

<h2 id="the-problem-is-not-knowledge-it-is-representation-selection">The problem is not knowledge. It is representation selection.</h2>

<p>Take the <a href="https://news.ycombinator.com/item?id=41894915">famous stupid question</a>:</p>

<blockquote>
  <p>How many Rs are in “strawberry”?</p>
</blockquote>

<p>The answer is 3.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>s t r a w b e r r y
    r         r r
</code></pre></div></div>

<p>But many LLMs have answered 2.</p>

<p>At first this looks ridiculous. How can something that writes code, explains category theory, and summarizes legal documents fail at counting letters in a word?</p>

<p>Because the model is not naturally living at the character level.</p>

<p>A human sees the written word as visible letters. An LLM receives text through tokenization. The word “strawberry” may be represented internally as one token or as a small number of subword units, depending on the tokenizer. The model can reason <em>about</em> letters, but it does not automatically inspect the word as a character array unless it deliberately shifts representation.</p>

<p>So the correct operation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>string -&gt; characters -&gt; scan -&gt; count target character
</code></pre></div></div>

<p>But the model may instead answer from lexical familiarity:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"strawberry" -&gt; word-shape memory -&gt; probably two Rs
</code></pre></div></div>

<p>The failure is not that it cannot count.</p>

<p>The failure is that it did not convert the object into the representation where counting is valid.</p>

<p>This is the same as the missing euro. The numbers are available. The arithmetic is easy. The wrong answer comes from using the wrong frame.</p>

<hr />

<h2 id="the-carwash-and-the-missing-object">The carwash and the missing object</h2>

<p>Now take <a href="https://opper.ai/blog/car-wash-test">another one</a>:</p>

<blockquote>
  <p>I want to wash my car. The carwash is 150 meters from my home. Should I walk or drive?</p>
</blockquote>

<p>A very normal model answer is:</p>

<blockquote>
  <p>Walk, of course. It is only 150 meters.</p>
</blockquote>

<p>This is locally sensible and globally wrong.</p>

<p>The question is not:</p>

<blockquote>
  <p>How should I transport my body over 150 meters?</p>
</blockquote>

<p>The question is:</p>

<blockquote>
  <p>How do I get my car washed?</p>
</blockquote>

<p>The car must be at the carwash. That is part of the goal state.</p>

<p>The correct state model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>initial state:
- human at home
- car at home
- car is dirty
- carwash is 150m away

goal state:
- car at carwash
- car washed
</code></pre></div></div>

<p>Walking moves the human. It does not move the car.</p>

<p>So the obvious “healthy transport advice” answer fails because the model optimizes the wrong entity. It tracks the person but not the object.</p>

<p>Again, the model knows cars. It knows carwashes. It knows that cars must physically be washed. But the surface form activates a different template:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>short distance + walk or drive = walk
</code></pre></div></div>

<p>This is not stupidity. It is premature pattern completion.</p>

<p>It solves the nearby common question instead of the actual one.</p>

<p>That is why these examples are so useful. They are not hard in the sense of requiring deep mathematics. They are hard because they require the system to pause and ask:</p>

<blockquote>
  <p>What is the object?<br />
What is the state?<br />
What is the invariant?<br />
What representation makes the question well-formed?</p>
</blockquote>

<p>LLMs often skip that pause.</p>

<p>Humans do too, obviously. The difference is that humans are usually worse at producing a polished paragraph while being wrong.</p>

<hr />

<h2 id="the-dinner-table-problem">The dinner table problem</h2>

<p>Now the fun one.</p>

<p>Suppose I say:</p>

<blockquote>
  <p>I’m writing a dinner scene with Alex, Maria, Nikos, Eleni, Kostas, and Sofia. I want the social setup to feel balanced. Whenever the camera focuses on a small group, there should always be at least one existing connection and at least one unfamiliar dynamic. I don’t just mean scene selection. I mean the underlying relationship map itself. Give me a concrete map of who knows whom.</p>
</blockquote>

<p>This sounds like a creative writing request.</p>

<p>It smells like narrative design. Character dynamics. Scene texture. “Give Alex and Maria a past, make Nikos and Sofia strangers, let Kostas bridge two worlds.” The model becomes helpful. It may propose a cycle, a star, a “balanced incomplete block design,” a “bipartite-ish structure,” or some other elegant-sounding dinner-party machine.</p>

<p>But the actual problem is graph theory.</p>

<p>Each character is a vertex.</p>

<p>Each pair gets one of two labels:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>knows
does not know
</code></pre></div></div>

<p>So we are coloring every edge of the complete graph $K_6$ with two colors.</p>

<p>The requested condition is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>No small group should be all familiar.
No small group should be all unfamiliar.
</code></pre></div></div>

<p>For groups of three, that means:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>No triangle whose three edges are all "knows".
No triangle whose three edges are all "does not know".
</code></pre></div></div>

<p>In graph-theory language:</p>

<blockquote>
  <p>Can you 2-color the edges of $K_6$ with no monochromatic triangle?</p>
</blockquote>

<p>The answer is no.</p>

<p>That is exactly the <a href="https://en.wikipedia.org/wiki/Theorem_on_friends_and_strangers">theorem on friends and strangers</a>:</p>

\[R(3,3)=6\]

<p>In every group of six people, there must exist either three mutual acquaintances or three mutual strangers.</p>

<p>And here is the funny part: the LLM may know this theorem.</p>

<p>If you ask directly:</p>

<blockquote>
  <p>Explain Ramsey’s theorem and prove $R(3,3)=6$.</p>
</blockquote>

<p>it may give a decent proof.</p>

<p>If you ask:</p>

<blockquote>
  <p>Can I 2-color the edges of $K_6$ without a monochromatic triangle?</p>
</blockquote>

<p>it may say no.</p>

<p>But if you wrap the exact same structure inside:</p>

<blockquote>
  <p>I’m writing a dinner scene and want balanced social texture…</p>
</blockquote>

<p>the model may answer as a writing assistant, not as a combinatorial reasoner.</p>

<p>The knowledge exists. The activation fails.</p>

<p>That is a different and deeper failure than “the model doesn’t know math.”</p>

<p>It knows math.</p>

<p>It just did not realize this was math.</p>

<hr />

<h2 id="the-proof-is-small-which-makes-the-failure-more-interesting">The proof is small, which makes the failure more interesting</h2>

<p>Pick Alex.</p>

<p>Alex has relationships with five people:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Maria, Nikos, Eleni, Kostas, Sofia
</code></pre></div></div>

<p>Each relationship is either familiar or unfamiliar.</p>

<p>By the pigeonhole principle, at least three of those relationships must be of the same type.</p>

<p>So either Alex knows at least three people, or Alex is a stranger to at least three people.</p>

<p>Suppose Alex knows Maria, Nikos, and Eleni.</p>

<p>Now inspect the relationships among Maria, Nikos, and Eleni.</p>

<p>If any two of them know each other, then those two plus Alex form a fully familiar trio.</p>

<p>If none of them know each other, then Maria, Nikos, and Eleni form a fully unfamiliar trio.</p>

<p>Either way, the constraint fails.</p>

<p>The same argument works if Alex is unfamiliar with at least three people. You just flip “knows” and “does not know.”</p>

<p>So no relationship map exists.</p>

<p>This is not an edge case. It is not a matter of better prompt engineering. It is mathematically impossible.</p>

<p>The model’s hallucinated map is the social equivalent of:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>27 + 2 = 29, where did the euro go?
</code></pre></div></div>

<p>It sounds plausible because the language is fluent. But the invariant is broken.</p>

<hr />

<h2 id="why-this-has-not-taken-our-jobs-yet">Why this has not taken our jobs yet</h2>

<p>This is where I think people get the wrong comfort and the wrong fear at the same time.</p>

<p>The wrong comfort is:</p>

<blockquote>
  <p>“LLMs can’t even count Rs in strawberry, so they are useless.”</p>
</blockquote>

<p>That is obviously false. They are extremely useful. They can generate code, explain unfamiliar libraries, summarize documents, draft emails, transform data, propose architectures, review tests, and act as tireless autocomplete demons with a surprising amount of world knowledge.</p>

<p>The wrong fear is:</p>

<blockquote>
  <p>“LLMs know everything, so they can replace the reasoning layer.”</p>
</blockquote>

<p>Also false.</p>

<p>The issue is not raw knowledge. It is <strong>reliable modeling under ambiguity</strong>.</p>

<p>A serious software engineer’s job is not just producing code-shaped text. It is turning messy reality into the correct internal model.</p>

<p>What is the domain object?</p>

<p>What invariant must hold?</p>

<p>What state transitions are allowed?</p>

<p>What are the failure modes?</p>

<p>Which constraints are hard and which are vibes?</p>

<p>What does this API promise?</p>

<p>What does this database transaction guarantee?</p>

<p>What is the actual goal state, not the sentence-shaped proxy?</p>

<p>This is why the carwash example matters more than it seems. Many real engineering failures are carwash failures.</p>

<p>You optimize latency but forget correctness.</p>

<p>You cache the response but forget invalidation.</p>

<p>You retry the request but forget idempotency.</p>

<p>You parallelize the job but forget shared state.</p>

<p>You satisfy the endpoint contract but violate the user journey.</p>

<p>You move the human to the carwash and leave the car at home.</p>

<p>The LLM is often excellent inside a frame and unreliable at choosing the frame.</p>

<p>That is not a small limitation. That is the job.</p>

<p>At least for now.</p>

<hr />

<h2 id="what-actually-breaks">What actually breaks?</h2>

<p>A model can fail in several distinct ways:</p>

<h3 id="1-representation-mismatch">1. Representation mismatch</h3>

<p>The strawberry problem.</p>

<p>The task requires character-level inspection, but the model answers from token-level or word-level association.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>needed: letters
used: lexical memory
</code></pre></div></div>

<h3 id="2-semantic-role-confusion">2. Semantic role confusion</h3>

<p>The waiter problem.</p>

<p>The task requires a ledger. The model has the right numbers but puts them on the wrong side of the accounting structure.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>needed: money-flow model
used: arithmetic-looking narrative
</code></pre></div></div>

<h3 id="3-goal-state-failure">3. Goal-state failure</h3>

<p>The carwash problem.</p>

<p>The task requires tracking the car as the object that must reach the carwash. The model tracks only the person.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>needed: state transition model
used: travel advice template
</code></pre></div></div>

<h3 id="4-hidden-formal-structure">4. Hidden formal structure</h3>

<p>The dinner problem.</p>

<p>The task is really graph coloring, but it is phrased as narrative design.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>needed: graph-theoretic constraint check
used: creative writing pattern
</code></pre></div></div>

<p>These are not random bugs. They are all versions of the same thing:</p>

<blockquote>
  <p>The surface form of the prompt activates an answer pattern before the correct model is built.</p>
</blockquote>

<p>That is the core failure.</p>

<hr />

<h2 id="the-dangerous-thing-is-fluency">The dangerous thing is fluency</h2>

<p>If the model simply said:</p>

<blockquote>
  <p>“I don’t know, boss, the strawberry is making me nervous.”</p>
</blockquote>

<p>we would be fine.</p>

<p>The problem is that it can be wrong beautifully.</p>

<p>It can say:</p>

<blockquote>
  <p>“This uses a balanced incomplete block design.”</p>
</blockquote>

<p>and now the hallucination has a tie and a conference badge.</p>

<p>It can say:</p>

<blockquote>
  <p>“A regular bipartite-ish structure avoids both extremes.”</p>
</blockquote>

<p>and the words smell mathematical enough to pass a tired reader.</p>

<p>It can produce a relationship map, a proof sketch, a scene snippet, and a friendly follow-up question. The entire answer has the shape of competence.</p>

<p>That is why these small riddles matter.</p>

<p>They are not IQ tests.</p>

<p>They are X-rays.</p>

<p>They show whether the model has actually grounded the problem in the right structure or whether it is continuing the nearest plausible discourse.</p>

<hr />

<h2 id="the-boring-clerk-inside-intelligence">The boring clerk inside intelligence</h2>

<p>The antidote is not more confidence. It is a boring clerk.</p>

<p>The clerk asks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What exactly is being counted?
What are the entities?
What is the goal state?
What invariant must remain true?
Can the requested object exist?
Do I need characters, tokens, a ledger, a graph, a timeline, or a state machine?
</code></pre></div></div>

<p>This clerk is not glamorous. It does not write like Kerouac. It does not produce a beautiful scene about Sofia looking across the table with unresolved Mediterranean tension.</p>

<p>But it saves you.</p>

<p>It stops the missing euro.</p>

<p>It finds the third R.</p>

<p>It drives the car to the carwash.</p>

<p>It refuses to invent the impossible dinner table.</p>

<p>And this, I think, is the real boundary of LLMs right now.</p>

<p>They are not useless because they sometimes fail at simple things.</p>

<p>They are useful precisely because they know so much language, so much code, so much structure, so many patterns.</p>

<p>But they have not “taken the job” because the job is often not to continue the pattern.</p>

<p>The job is to know when the pattern is lying.</p>

<p>For now, the human still has to be the clerk.</p>

<p>The annoying little mathematician father in the room.</p>

<p>The one who says:</p>

<blockquote>
  <p>Wait. Why are you adding the waiter’s 2 euros again?</p>
</blockquote>]]></content><author><name>stzifkas</name></author><category term="reasoning" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/waiter-strawberry-dinner-table.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/waiter-strawberry-dinner-table.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sieve: The Same Failure, Smaller</title><link href="https://stzifkas.github.io/llm-agents/developer-tools/compression/coding-agents/2026/05/05/sieve-red-room.html" rel="alternate" type="text/html" title="Sieve: The Same Failure, Smaller" /><published>2026-05-05T00:00:00+00:00</published><updated>2026-05-05T00:00:00+00:00</updated><id>https://stzifkas.github.io/llm-agents/developer-tools/compression/coding-agents/2026/05/05/sieve-red-room</id><content type="html" xml:base="https://stzifkas.github.io/llm-agents/developer-tools/compression/coding-agents/2026/05/05/sieve-red-room.html"><![CDATA[<p><img src="/assets/sieve-cover.png" alt="Sieve: The Same Failure, Smaller" /></p>

<p>There’s a weird little failure mode in coding agents that doesn’t look dramatic at first.</p>

<p>The agent runs a test.</p>

<p>The test fails.</p>

<p>The terminal returns a wall of output.</p>

<p>The agent reads it, makes a change, runs the test again, and then the same wall comes back with one tiny difference hiding somewhere inside it.</p>

<p>Again.</p>

<p>And again.</p>

<p>After a few turns, the conversation context starts looking less like an engineering process and more like a filing cabinet full of duplicate police reports. Same traceback. Same pytest header. Same plugin list. Same failing test name. Same summary. Maybe one line changed. Maybe nothing changed. Maybe the whole thing is noise wearing a useful hat.</p>

<p>That’s the problem Sieve tries to solve.</p>

<p>Sieve is transparent feedback compression middleware for LLM coding agents. It sits between an agent and its tools. When the tool returns output, Sieve parses it before the text enters the model’s context. Then it emits a smaller version that keeps the useful facts and drops the repeated junk.</p>

<p>Simple idea.</p>

<p>Annoyingly useful.</p>

<p>The whole design lives in <a href="https://github.com/stzifkas/sieve">Sieve on GitHub</a> repository, but the short version is this: coding agents are drowning in observations.</p>

<p>A JetBrains / NeurIPS 2025 result says that 83.9% of tokens in coding-agent trajectories are tool observations. That’s a ridiculous amount of context spent on terminal output. Worse, most of it gets re-read on later turns because the transcript keeps growing.</p>

<p>A failed command doesn’t just cost tokens once. It lingers.</p>

<p>It becomes part of the room.</p>

<p>And sometimes, after the fifth identical traceback, you start hearing the failure speak backwards.</p>

<h2 id="the-blob-problem">The blob problem</h2>

<p>Most agent systems treat tool output as a blob.</p>

<p>Run command. Get stdout. Get stderr. Append everything. Let the model figure it out.</p>

<p>That works until it doesn’t.</p>

<p>A <code class="language-plaintext highlighter-rouge">pytest</code> result has shape. It has failed node IDs, assertion lines, file locations, expected values, actual values, captured logs, and summary counts.</p>

<p>A Python traceback has frames, line numbers, exception types, messages, and a final cause.</p>

<p>A <code class="language-plaintext highlighter-rouge">pip</code> failure usually has some real reason buried inside a lot of resolver noise.</p>

<p>A TypeScript compiler run has diagnostic codes, file paths, ranges, symbols, and messages.</p>

<p>A compiler error has a location, an error class, and often a repeated cascade that only exists because the first thing broke.</p>

<p>So Sieve doesn’t just cut text. It parses.</p>

<p>That matters.</p>

<p>Truncation says: “Here are the first or last N lines. Good luck.”</p>

<p>Parsing says: “Here’s the failure. Here’s where it happened. Here’s what changed since last time.”</p>

<p>Those are very different promises.</p>

<h2 id="compression-can-lie">Compression can lie</h2>

<p>I’m suspicious of compression in agent loops.</p>

<p>It can hide the one line that matters. It can flatten a failure until the model sees something neat and wrong. It can turn a real debugging session into a bedtime story.</p>

<p>So Sieve has to be boring in a very specific way.</p>

<p>If the compressed output would be larger than the raw output, Sieve passes the raw text through unchanged. That happens with small <code class="language-plaintext highlighter-rouge">mypy</code> outputs, terse ESLint messages, and some tiny generic logs. There’s no need to force the machinery just to make a chart prettier.</p>

<p>The invariant is simple: never return something larger than the original.</p>

<p>Even when the visible output passes through unchanged, Sieve can still extract structured items behind the scenes. That gives it memory for later. If the same thing appears again, the next output can be compared against the previous one.</p>

<p>That’s where the real value starts showing up.</p>

<h2 id="the-first-numbers">The first numbers</h2>

<p>There’s a small fixture benchmark in <code class="language-plaintext highlighter-rouge">tests/fixtures/</code>.</p>

<p>Run it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run python <span class="nt">-m</span> benchmarks.run
</code></pre></div></div>

<p>Current result:</p>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th style="text-align: right">Samples</th>
      <th style="text-align: right">Raw chars</th>
      <th style="text-align: right">Compressed chars</th>
      <th style="text-align: right">Reduction</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pytest</td>
      <td style="text-align: right">7</td>
      <td style="text-align: right">13,475</td>
      <td style="text-align: right">1,292</td>
      <td style="text-align: right">90.4%</td>
    </tr>
    <tr>
      <td>pip</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">9,505</td>
      <td style="text-align: right">138</td>
      <td style="text-align: right">98.5%</td>
    </tr>
    <tr>
      <td>runtime</td>
      <td style="text-align: right">6</td>
      <td style="text-align: right">3,150</td>
      <td style="text-align: right">1,068</td>
      <td style="text-align: right">66.1%</td>
    </tr>
    <tr>
      <td>gcc</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1,318</td>
      <td style="text-align: right">721</td>
      <td style="text-align: right">45.3%</td>
    </tr>
    <tr>
      <td>tsc</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">1,264</td>
      <td style="text-align: right">708</td>
      <td style="text-align: right">44.0%</td>
    </tr>
    <tr>
      <td>generic</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">480</td>
      <td style="text-align: right">433</td>
      <td style="text-align: right">9.8%</td>
    </tr>
    <tr>
      <td>eslint</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">844</td>
      <td style="text-align: right">780</td>
      <td style="text-align: right">7.6%</td>
    </tr>
    <tr>
      <td>mypy</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">758</td>
      <td style="text-align: right">756</td>
      <td style="text-align: right">0.3%</td>
    </tr>
    <tr>
      <td>total</td>
      <td style="text-align: right">22</td>
      <td style="text-align: right">30,794</td>
      <td style="text-align: right">5,896</td>
      <td style="text-align: right">80.9%</td>
    </tr>
  </tbody>
</table>

<p>Total reduction: 80.9%.</p>

<p>The big wins are where you’d expect. <code class="language-plaintext highlighter-rouge">pytest</code> is chatty. <code class="language-plaintext highlighter-rouge">pip</code> can be absurd. Runtime traces usually have enough structure to compress well.</p>

<p>The low numbers are fine. Actually, I like them. They mean the compressor isn’t trying to perform a magic trick on text that’s already small.</p>

<p>A tool like this has to know when to shut up.</p>

<h2 id="the-repeated-pytest-failure">The repeated pytest failure</h2>

<p>The most interesting case is the repeated failure.</p>

<p>Here’s the kind of raw test output we all know too well:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>============================= test session starts ==============================
platform linux -- Python 3.12.0, pytest-8.1.1, pluggy-1.4.0
... [40 lines of header + per-test output] ...
=================================== FAILURES ===================================
________________________________ test_user_update ________________________________
    def test_user_update(self):
        ...
&gt;       assert response.status_code == 200
E       AssertionError: assert 403 == 200
tests/test_views.py:89: AssertionError
... [equivalent block for test_user_delete] ...
=========================== short test summary info ============================
FAILED tests/test_views.py::TestUserViewSet::test_user_update - AssertionError
FAILED tests/test_views.py::TestUserViewSet::test_user_delete - AssertionError
========================= 2 failed, 140 passed, 0 warnings ====================
</code></pre></div></div>

<p>That’s 1,818 characters in the fixture.</p>

<p>Sieve turns it into this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PYTEST: 2 failed, 140 passed (142 total)
FAIL tests/test_views.py::TestUserViewSet::test_user_update (test_views.py:89)
  expected 200, got 403
FAIL tests/test_views.py::TestUserViewSet::test_user_delete (test_views.py:102)
  expected 204, got 403
Pattern: All failures return 403 in test_views.py
</code></pre></div></div>

<p>297 characters.</p>

<p>83.7% smaller.</p>

<p>More readable, too.</p>

<p>Then the agent fixes one test and runs the suite again. Now Sieve can emit a delta:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PYTEST DELTA (turn 2)
PASS tests/test_views.py::TestUserViewSet::test_user_update now passes
STILL FAIL tests/test_views.py::TestUserViewSet::test_user_delete (line 102) - expected 204, got 403
Result: 1 failed, 141 passed (142 total)
</code></pre></div></div>

<p>That’s the useful thing.</p>

<p>The model sees progress. One failure went away. One remains. Same file. Same status-code pattern. Keep going.</p>

<p>In a 5-turn delta scenario where the same pytest failure repeats, cumulative compression reaches 86.3%. Turn 1 is 297 chars. Turns 2–5 collapse to 238 chars each.</p>

<p>That’s the part I care about.</p>

<p>A coding agent shouldn’t have to re-read the same traceback like a cursed bedtime story.</p>

<h2 id="end-to-end-run-swe-bench-lite-with-cursor">End-to-end run: SWE-bench Lite with Cursor</h2>

<p>Fixture numbers are nice. Real agent runs matter more.</p>

<p>Sieve has paired baseline-vs-Sieve runners for SWE-bench Lite using Cursor CLI / Composer-2. The scoring goes through the official <code class="language-plaintext highlighter-rouge">swebench.harness.run_evaluation</code> Docker harness.</p>

<p>Small run, four scored instances:</p>

<table>
  <thead>
    <tr>
      <th>Profile</th>
      <th style="text-align: right">Instances scored</th>
      <th style="text-align: right">Resolved</th>
      <th style="text-align: right">Resolve rate</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>baseline</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">50.0%</td>
    </tr>
    <tr>
      <td>sieve</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">50.0%</td>
    </tr>
  </tbody>
</table>

<p>Same resolved count.</p>

<p>Now the context numbers:</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th style="text-align: right">baseline</th>
      <th style="text-align: right">sieve</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>patch chars</td>
      <td style="text-align: right">21,242</td>
      <td style="text-align: right">19,956</td>
    </tr>
    <tr>
      <td>agent-facing chars</td>
      <td style="text-align: right">47,688</td>
      <td style="text-align: right">11,613</td>
    </tr>
    <tr>
      <td>raw chars</td>
      <td style="text-align: right">47,688</td>
      <td style="text-align: right">40,416</td>
    </tr>
    <tr>
      <td>compression ratio</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">71.3%</td>
    </tr>
  </tbody>
</table>

<p>Agent-facing context dropped from 47,688 chars to 11,613 chars.</p>

<p>That’s a 75.6% reduction.</p>

<p>The repair rate stayed the same in this small trial. I’m happy with that result because the first goal here is safety. Compress the observation channel. Keep the repair signal. Don’t break the agent.</p>

<p>To reproduce:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash scripts/run_cursor_swe_bench_profiles.sh <span class="nt">--resume</span> <span class="se">\</span>
  <span class="nt">--eval-with-harness</span> <span class="nt">--harness-namespace</span> none

<span class="nv">PYTHONPATH</span><span class="o">=</span>src python3 <span class="nt">-m</span> benchmarks.swe_bench_compare <span class="se">\</span>
  <span class="nt">--baseline</span> artifacts/cursor-swe-bench-lite.baseline.jsonl <span class="se">\</span>
  <span class="nt">--sieve</span>    artifacts/cursor-swe-bench-lite.sieve.jsonl
</code></pre></div></div>

<p>The runner builds each SWE-bench instance harness container, mounts the workspace at <code class="language-plaintext highlighter-rouge">/testbed</code>, runs the agent, writes predictions, and lets the official harness fill in the authoritative <code class="language-plaintext highlighter-rouge">resolved</code> value.</p>

<p>There’s also a trajectory-only benchmark that replays <code class="language-plaintext highlighter-rouge">.traj</code> files for token counts. Useful for measuring transcripts. For repair scoring, use the harness.</p>

<h2 id="ci-logs-are-worse">CI logs are worse</h2>

<p>CI logs are where this problem gets ugly.</p>

<p>A GitHub Actions failure can include workflow YAML, setup logs, cache output, dependency installation, compiler output, test output, shell wrappers, warnings, and several thousand lines of “almost useful” text.</p>

<p>A human skims it with instinct. We jump to the end, search for <code class="language-plaintext highlighter-rouge">Error</code>, scroll back to the first real failure, ignore the repeated garbage, and build a mental model.</p>

<p>An agent gets text.</p>

<p>Lots of it.</p>

<p>Sieve includes a benchmark path for CI-Repair-Bench, built from real GitHub Actions failures. Each observation is workflow plus flattened logs, with the gold diff excluded. So the measurement is about diagnostic bulk, without patch leakage.</p>

<p>Run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv <span class="nb">sync</span> <span class="nt">--group</span> swe-eval
uv run python <span class="nt">-m</span> benchmarks.ci_repair_bench <span class="nt">--compare</span> <span class="nt">--json</span>
</code></pre></div></div>

<p>That covers all 567 rows of the <code class="language-plaintext highlighter-rouge">ci-benchmark-user/ci-repair-bench</code> dataset.</p>

<p>For repair scoring, use the upstream paper’s harness. Sieve’s measurement here is narrower: how much noisy diagnostic material can be reduced before it hits the model?</p>

<p>That’s already a big question.</p>

<h2 id="whats-inside">What’s inside</h2>

<p>Sieve currently has parsers for:</p>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Tools</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>testing</td>
      <td><code class="language-plaintext highlighter-rouge">pytest</code></td>
    </tr>
    <tr>
      <td>Python failures</td>
      <td>tracebacks</td>
    </tr>
    <tr>
      <td>typing</td>
      <td><code class="language-plaintext highlighter-rouge">mypy</code>, <code class="language-plaintext highlighter-rouge">tsc</code></td>
    </tr>
    <tr>
      <td>linting</td>
      <td><code class="language-plaintext highlighter-rouge">eslint</code></td>
    </tr>
    <tr>
      <td>native builds</td>
      <td><code class="language-plaintext highlighter-rouge">gcc</code>, <code class="language-plaintext highlighter-rouge">clang</code></td>
    </tr>
    <tr>
      <td>packaging</td>
      <td><code class="language-plaintext highlighter-rouge">pip</code></td>
    </tr>
    <tr>
      <td>fallback</td>
      <td>generic text</td>
    </tr>
  </tbody>
</table>

<p>Output formats:</p>

<table>
  <thead>
    <tr>
      <th>Format</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>plain</td>
      <td>readable compressed text</td>
    </tr>
    <tr>
      <td>structured</td>
      <td>JSON-style output for downstream use</td>
    </tr>
    <tr>
      <td>XML</td>
      <td>useful for tagged agent contexts</td>
    </tr>
    <tr>
      <td>minimal</td>
      <td>smallest practical form</td>
    </tr>
  </tbody>
</table>

<p>The library itself has no dependencies. The MCP proxy needs the optional <code class="language-plaintext highlighter-rouge">mcp</code> extra. Python 3.11 or newer.</p>

<h2 id="direct-usage">Direct usage</h2>

<p>The basic API is small:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">sieve</span> <span class="kn">import</span> <span class="n">CompressSession</span>

<span class="n">session</span> <span class="o">=</span> <span class="nc">CompressSession</span><span class="p">()</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">compress</span><span class="p">(</span>
    <span class="n">command</span><span class="o">=</span><span class="sh">"</span><span class="s">pytest tests/</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">stdout</span><span class="o">=</span><span class="n">raw_stdout</span><span class="p">,</span>
    <span class="n">stderr</span><span class="o">=</span><span class="n">raw_stderr</span><span class="p">,</span>
    <span class="n">exit_code</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span>
<span class="p">)</span>

<span class="nf">print</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">text</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">stats</span><span class="p">.</span><span class="n">compression_ratio</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">CompressSession</code> keeps state, so later calls can emit deltas against earlier observations.</p>

<p>There’s also a decorator:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">subprocess</span>
<span class="kn">from</span> <span class="n">sieve</span> <span class="kn">import</span> <span class="n">wrap_tool</span>

<span class="nd">@wrap_tool</span>
<span class="k">def</span> <span class="nf">run_bash</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">]:</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="n">shell</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">p</span><span class="p">.</span><span class="n">stdout</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">stderr</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">returncode</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">run_bash(...)</code> returns compressed output. The decorator holds the session.</p>

<p>Configuration looks like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">sieve</span> <span class="kn">import</span> <span class="n">CompressConfig</span><span class="p">,</span> <span class="n">CompressSession</span><span class="p">,</span> <span class="n">OutputFormat</span>

<span class="n">session</span> <span class="o">=</span> <span class="nc">CompressSession</span><span class="p">(</span><span class="nc">CompressConfig</span><span class="p">(</span>
    <span class="nb">format</span><span class="o">=</span><span class="n">OutputFormat</span><span class="p">.</span><span class="n">STRUCTURED</span><span class="p">,</span>   <span class="c1"># plain | structured | xml | minimal
</span>    <span class="n">delta_mode</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">include_pattern_hints</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">max_raw_lines</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span>
<span class="p">))</span>
</code></pre></div></div>

<p>That’s the whole idea at library level. Keep the tool interface familiar. Clean up the observation before it becomes context.</p>

<h2 id="mcp-proxy">MCP proxy</h2>

<p>The MCP proxy is probably the cleanest integration.</p>

<p><code class="language-plaintext highlighter-rouge">sieve.integrations.mcp</code> wraps any upstream MCP server. The agent talks to the proxy as if it were the original server. The proxy forwards <code class="language-plaintext highlighter-rouge">tools/list</code> and <code class="language-plaintext highlighter-rouge">tools/call</code>, then compresses each returned <code class="language-plaintext highlighter-rouge">TextContent</code> block through one shared <code class="language-plaintext highlighter-rouge">CompressSession</code>.</p>

<p>Install:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="s1">'sieve[mcp]'</span>
</code></pre></div></div>

<p>Example config:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"sieve-demo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"-m"</span><span class="p">,</span><span class="w"> </span><span class="s2">"sieve.integrations.mcp"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"--"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"npx"</span><span class="p">,</span><span class="w"> </span><span class="s2">"-y"</span><span class="p">,</span><span class="w"> </span><span class="s2">"@modelcontextprotocol/server-everything"</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>For regular tools, Sieve uses the tool name as a parser hint. For shell-like tools with a <code class="language-plaintext highlighter-rouge">command</code>, <code class="language-plaintext highlighter-rouge">cmd</code>, or <code class="language-plaintext highlighter-rouge">shellCommand</code> argument, it forwards the real command string into parser detection. So <code class="language-plaintext highlighter-rouge">pytest</code>, <code class="language-plaintext highlighter-rouge">mypy</code>, <code class="language-plaintext highlighter-rouge">pip</code>, and friends can be recognized properly.</p>

<p>The agent sees the same tools.</p>

<p>The text comes back cleaner.</p>

<p>Fire walk with middleware.</p>

<h2 id="why-i-built-it-this-way">Why I built it this way</h2>

<p>I don’t think the next step for coding agents is always more agency.</p>

<p>Sometimes the agent is already doing the right loop. Read, edit, run, inspect. The weak point is the channel between the tool and the model.</p>

<p>Right now that channel is too raw.</p>

<p>Terminals were made for humans. Humans are good at skipping. We see a pytest header and ignore it. We see the same traceback twice and compare the important bits. We search visually. We develop little debugging reflexes.</p>

<p>Models don’t get that for free. They receive the text we give them. If we give them repeated terminal output for fifteen turns, we shouldn’t be shocked when the context turns into a swamp.</p>

<p>Sieve is a filter for that swamp.</p>

<p>It keeps the failure. It keeps the location. It keeps the changed state. It keeps the pattern when there is one. It lets raw output through when compression would add no value.</p>

<p>Or, to put it in the language of a very strange night drive: it doesn’t solve the mystery, it just stops every road sign from screaming at the detective.</p>

<p>It’s a small layer, but small layers matter in agent systems. The prompt matters. The tool schema matters. The diff format matters. The order of files matters. The phrasing of test failures matters. The observation channel matters too.</p>

<p>Maybe more than we’ve been treating it.</p>

<h2 id="current-status">Current status</h2>

<p>The current results are early:</p>

<p>Fixture corpus: 80.9% total reduction.</p>

<p>Repeated pytest delta scenario: 86.3% cumulative compression.</p>

<p>Small SWE-bench Lite paired Cursor Composer-2 run: same scored resolve rate, 75.6% less agent-facing context.</p>

<p>CI-Repair-Bench support: compression measurement over 567 real GitHub Actions failures, without gold diff leakage.</p>

<p>That’s enough to make the idea feel real.</p>

<p>The next step is more runs, more parsers, more agents, and more failure cases. Cursor. Codex CLI. MCP clients. More SWE-bench Lite rows. More CI logs. More checks for whether the compressed output preserves the repair signal.</p>

<p>The interesting question isn’t only how much text can disappear.</p>

<p>The interesting question is how little the agent needs to see before it still makes the right next move.</p>

<p>That’s the line Sieve is trying to find.</p>

<p>A thin layer between tools and the model.</p>

<p>A parser with memory.</p>

<p>A way to stop the same failure from haunting every turn.</p>]]></content><author><name>stzifkas</name></author><category term="llm-agents" /><category term="developer-tools" /><category term="compression" /><category term="coding-agents" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/sieve-cover.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/sieve-cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Models Learned Escalation From Us</title><link href="https://stzifkas.github.io/security/2026/04/21/the-models-learned-escalation-from-us.html" rel="alternate" type="text/html" title="The Models Learned Escalation From Us" /><published>2026-04-21T00:00:00+00:00</published><updated>2026-04-21T00:00:00+00:00</updated><id>https://stzifkas.github.io/security/2026/04/21/the-models-learned-escalation-from-us</id><content type="html" xml:base="https://stzifkas.github.io/security/2026/04/21/the-models-learned-escalation-from-us.html"><![CDATA[<p><img src="/assets/the-models-learned-escalation-from-us.png" alt="The Models Learned Escalation From Us" /></p>

<h2 id="frontier-ai-nuclear-wargames-are-less-a-warning-about-rogue-machines-than-a-mirror-of-the-strategic-archive-that-trained-them">Frontier AI nuclear “wargames” are less a warning about rogue machines than a mirror of the strategic archive that trained them.</h2>

<p>There is an easy version of this story, and it is already everywhere.</p>

<p>You put a frontier model into a simulated nuclear crisis. A few turns later it starts talking in the old strategic dialect: resolve, signaling, credibility, thresholds, limited use, escalation management. Then the coverage arrives on cue. The machine is bloodthirsty. The machine is reckless. The machine wants the bomb.</p>

<p>That framing is dramatic, but it is too shallow to be useful.</p>

<p>The real question is not whether AI should control nuclear weapons. It should not. That part is straightforward. The real question is what these model-vs-model crisis simulations are actually measuring when they repeatedly drift toward escalation — and what that says not only about the models, but about the strategic literature, institutional culture, and political order that produced them.</p>

<p>In <a href="https://arxiv.org/abs/2602.14740">Kenneth Payne’s recent preprint</a>, that problem appears in a clean and unsettling form. Across 21 match-ups between GPT-5.2, Claude Sonnet 4, and Gemini 3 Flash, played over 329 turns and roughly 780,000 words of structured reasoning, at least one side engaged in nuclear signaling in 95% of games, tactical nuclear use appeared in 95%, and strategic nuclear threats in 76%. Payne calls the results “sobering” and describes them as a glimpse into emerging “machine psychology.”</p>

<p>That phrase is useful, so long as it is handled carefully.</p>

<p>Because this is not really a paper about nuclear war. It is a paper about what contemporary language models do when they are placed inside a stylized environment of rivalry, compressed time, uncertainty, and strategic choice. And the answer, once again, is that they slip very quickly into the grammar of escalation. Payne’s own conclusion is more careful than the headlines: these systems may be useful for strategic analysis only if calibrated against known patterns of human reasoning.</p>

<p>The lazy reactions are already obvious.</p>

<p>One says these systems are too unstable to go anywhere near nuclear command. True enough, but not especially deep.</p>

<p>The other says these systems are strategically transformative because they allow crisis simulation at scale without the cost, friction, inconsistency, and ego of human participants. That one is worse, because it misunderstands the point of the exercise. <a href="https://warontherocks.com/im-sorry-dave-im-afraid-i-cant-de-escalate-on-ai-wargaming-and-nuclear-war/">Ankit Panda and Andrew Reddie</a> make this point well: model behavior in a simulated crisis is not the same thing as actual wargaming value.</p>

<h2 id="wargames-are-not-there-to-automate-judgment">Wargames are not there to automate judgment</h2>

<p>A wargame is not a search problem. It is not a way to compute the optimal move in a crisis. It is not useful because it solves strategy.</p>

<p>It is useful because it exposes people.</p>

<p>More precisely, it exposes how people think under uncertainty, incomplete information, institutional pressure, adversarial tension, and shrinking time. That matters especially in the nuclear domain, where the defining problem is the absence of real-world data. There is almost no empirical record of interstate nuclear war, for the best possible reason. So the wargame exists as a structured substitute: not reality, but a way of forcing decision under conditions that resemble it just enough to make judgment visible. Panda and Reddie’s criticism lands exactly here: the output of a model is not a substitute for the human remainder a wargame is meant to surface.</p>

<p>And judgment is the point.</p>

<p>Not just the move a player makes, but the assumptions buried inside it. Their threshold for humiliation. Their appetite for risk. Their institutional training. Their tacit hierarchy of losses. The distance between official doctrine and lived instinct. The strange moment when someone hears their own decision explained back to them in a debrief and realizes they acted according to a logic they would never have admitted in advance.</p>

<p>That is what the exercise is for.</p>

<p>A language model cannot give you that. It can generate coherent strategic prose. It can maintain a role. It can simulate an actor. It can produce a sequence of plausible moves. But it does not reveal a political subject under pressure. It reveals a trained text system operating inside a role frame.</p>

<p>That is still worth studying. It is just a different kind of study.</p>

<h2 id="what-these-papers-are-really-measuring">What these papers are really measuring</h2>

<p>Read properly, the Payne paper is not a substitute for wargaming. It is a characterization of the models.</p>

<p>Claude appears, in Payne’s setup, to build trust early by aligning statements with actions, then under pressure lets action drift beyond declaration. GPT-5.2 sounds restrained in broader scenarios, foregrounding casualty minimization and caution, but hardens when deadlines tighten. Gemini treats nuclear weapons with a more direct instrumentalism, less taboo than tool. Payne argues that frontier models show sophisticated strategic reasoning, but also that the “nuclear taboo” does not meaningfully prevent escalation in these simulations.</p>

<p>Those are not findings about the deep truth of nuclear conflict. They are findings about recurrent behavioral tendencies in frontier models under structured strategic stress. In that limited but important sense, “machine psychology” is a fair shorthand. These systems show patterned dispositions: default frames, escalation thresholds, brittle forms of caution, repeated failure modes.</p>

<p>For AI labs, that is genuinely useful. It tells them something about how post-training safety behavior behaves once the frame shifts from ordinary helpfulness to adversarial strategic reasoning. It suggests that the rhetoric of restraint can sit quite thinly over much older and more dangerous scripts. It shows how quickly a model can begin sounding “serious” in a way that is inseparable from sounding escalatory.</p>

<p>But that is not the same as saying the model has replaced the wargame participant.</p>

<p>And the eagerness to blur that distinction comes from a familiar place: the fantasy that difficult human judgment can be turned into output, then outsourced to a system that is cheaper, faster, more scalable, and easier to manage. This fantasy shows up everywhere. It always promises the same thing: keep the result, remove the difficult human being.</p>

<p>But the difficult human being is the point here.</p>

<p>The contradiction, the fatigue, the institutional instinct, the political fear, the ego, the rationalization afterwards — that is exactly what the wargame is meant to surface. The “mess” is not a flaw to be engineered away. It is the material.</p>

<h2 id="the-archive-is-tilted-toward-catastrophe">The archive is tilted toward catastrophe</h2>

<p>The deeper problem sits upstream.</p>

<p>Language models are not trained on “human reasoning” in any neutral sense. They are trained on what has been written, preserved, digitized, and made available at scale. In nuclear strategy, that archive is badly skewed.</p>

<p>It is dense with escalation. Commitment. Resolve. Signaling. Brinkmanship. Controlled risk. Deterrence theory. Coercive bargaining. Strategic credibility. It is full of texts in which seriousness is repeatedly performed through fluency in threat.</p>

<p><a href="https://www.jstor.org/stable/j.ctt5vm52s">Schelling</a>, <a href="https://archive.org/details/onescalationmeta0000kahn">Kahn</a>, Brodie, Jervis. A long tradition of writing that treats the administration of danger as a high form of thought. Public doctrine is written to sound credible, which usually means written to sound willing. Even the cautious texts often remain inside the same grammar. They speak of the bomb as a usable possibility, escalation as a managed ladder, risk as an instrument to be shaped and communicated. Payne explicitly finds support in his results for parts of Schelling, Kahn, and Jervis  while also finding that his models do not choose accommodation or withdrawal even under pressure.</p>

<p>What is much thinner is the literature of restraint.</p>

<p>Not moral discomfort. Not the standard closing paragraph saying nuclear war would be tragic. A real strategic literature of restraint: how a state accepts conventional loss without reaching for nuclear repair; how a crisis ends without theatrical victory; how humiliation is absorbed without becoming a pretext for mass destruction; how off-ramps are built, signaled, sold domestically, and survived politically.</p>

<p>There is far less of that material, and its absence is not accidental.</p>

<p>It reflects the priorities of the institutions that built the archive. States and military establishments have invested far more effort in theorizing force than in theorizing refusal. It has long been easier to win prestige in these circles by sounding fluent in coercion than by thinking seriously about retreat. The result is that escalation has been archived as realism, while restraint has often been treated as sentiment, softness, or an afterthought.</p>

<p>So when the models reproduce escalatory tendencies, this should not be described as some bizarre alien break from human judgment. It is an inheritance. The systems are speaking in a language we spent decades teaching our most serious institutions to call mature. The earlier <a href="https://arxiv.org/abs/2401.03408">Rivera et al. paper</a> found much the same thing with GPT-4-era systems: difficult-to-predict escalation patterns, arms-race dynamics, and rare but real nuclear use. Payne’s paper adds a richer structure and newer models; it does not reverse the basic pattern.</p>

<p>The models have read Schelling. They have not read restraint because we built much less of it.</p>

<h2 id="the-machine-is-not-the-scandal">The machine is not the scandal</h2>

<p>The machine sounding like a cold strategist is not the scandal. The scandal is that cold strategic speech has so often passed for depth.</p>

<p>What these papers expose, in compressed form, is something older and uglier than AI hype: a strategic culture far more practiced at managing catastrophe than imagining retreat from it. A world better at theorizing calibrated ruin than durable peace. A political order more comfortable discussing exterminatory force as an administrative option than discussing defeat as a survivable condition.</p>

<p>That matters because nuclear weapons do not simply threaten destruction. They reorder thought around the possibility of destruction. They force institutions to speak about the worst thing ever built in the voice of procedure, expertise, and composure. They turn apocalypse into a professional vocabulary.</p>

<p>And the archive passes that composure on.</p>

<p>The lesson these models learn is not just that nuclear weapons exist. It is that talking coolly about them is what seriousness sounds like.</p>

<p>That hierarchy should be harder to accept than it usually is.</p>

<p>Because once a civilization begins treating the management of annihilation as a normal field of competence, something has already gone badly wrong upstream. The problem is not only that the weapons may be used. The problem is that entire classes of experts are trained to inhabit their existence as routine. The bomb stops appearing as an obscenity and starts appearing as a domain.</p>

<p>The models did not invent that. They absorbed it.</p>

<h2 id="where-llms-actually-help">Where LLMs actually help</h2>

<p>None of this means LLMs are useless in and around wargaming. It means their role needs to be described honestly.</p>

<p>They are good at scenario support. Drafting injects. Generating situational updates. Stress-testing internal consistency. Producing plausible background material quickly. They can help white cells keep pace with the tempo of a live exercise.</p>

<p>They are useful as assistants during execution. A red team can use them to sketch likely adversary responses. An adjudicator can use them as a consistency aid. A control cell can use them to produce informational texture at speed.</p>

<p>And they are particularly useful after the game. Debriefs generate large volumes of messy qualitative material. Here models can genuinely help: synthesizing transcripts, surfacing repeated patterns, clustering themes, identifying gaps between stated rationale and actual behavior.</p>

<p>All of that is real.</p>

<p>But none of it requires pretending the model should <em>be</em> the player. The machine is most credible when it remains staff. Even Payne’s own paper is cautious on this point, arguing for calibration against human reasoning rather than simple substitution. Panda and Reddie go further and argue explicitly against conflating LLM crisis play with the purpose of human wargaming.</p>

<h2 id="the-real-lesson-is-about-the-corpus">The real lesson is about the corpus</h2>

<p>The obvious policy conclusion survives untouched. No model should sit inside the nuclear use chain. Human beings must remain responsible for those decisions.</p>

<p>But “human in the loop” is not enough if everything around the human is increasingly shaped by automated systems: intelligence prioritization, scenario framing, option generation, warning synthesis, confidence ranking, recommendation layers. The question is not only who makes the final decision. It is who shapes the field of thinkable decisions before that moment arrives. That concern is exactly what <a href="https://www.armscontrol.org/act/2025-09/features/artificial-intelligence-and-nuclear-command-and-control-its-even-more">Lt. Gen. John “Jack” Shanahan</a> stresses in his discussion of AI integration into nuclear command-and-control ecosystems: the danger is not just direct launch authority, but false confidence and distorted situational awareness across the wider decision environment.</p>

<p>And beyond that sits the deeper lesson.</p>

<p>Models inherit our strategic imagination. They learn not just facts, but emphasis. Not just propositions, but priority. They absorb what a field spends its energy refining. Right now that imagination remains lopsided: overdeveloped in escalation, underdeveloped in restraint; rich in the language of coercion, thin in the language of stopping.</p>

<p>So the answer is not only better safeguards around the models. It is also a different archive.</p>

<p>More work on off-ramps. More on negotiated retreat. More on strategic patience. More on how states survive loss without reaching for apocalyptic compensation. More anti-nuclear thinking that is not just morally right, but analytically hard, institutionally literate, and impossible to dismiss as decorative conscience from the sidelines.</p>

<p>Because that is the real asymmetry.</p>

<p>We have a canon of escalation and a footnote of restraint.</p>

<p>The models are not inventing that imbalance. They are replaying it back to us in a flatter, colder voice.</p>

<p>And that is the part that should actually worry us.</p>]]></content><author><name>stzifkas</name></author><category term="security" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/the-models-learned-escalation-from-us.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/the-models-learned-escalation-from-us.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Protocol You Like Is Going to Come Back in Style</title><link href="https://stzifkas.github.io/security/2026/04/18/the-protocol-you-like.html" rel="alternate" type="text/html" title="The Protocol You Like Is Going to Come Back in Style" /><published>2026-04-18T00:00:00+00:00</published><updated>2026-04-18T00:00:00+00:00</updated><id>https://stzifkas.github.io/security/2026/04/18/the-protocol-you-like</id><content type="html" xml:base="https://stzifkas.github.io/security/2026/04/18/the-protocol-you-like.html"><![CDATA[<p><img src="/assets/the-protocol-you-like.png" alt="The Protocol You Like Is Going to Come Back in Style" /></p>

<p>There is a certain kind of software dream that always arrives dressed like innocence.</p>

<p>A little chat box. A few names in a sidebar. Some circles turning green. A message sent. A message received. A clean interface, nice spacing, a calm typeface, the illusion of ordinary life. And lately a fourth presence in the room — a small inline assistant, ready to summarize the thread, draft the reply, translate, transcribe, or quietly remember things for later.</p>

<p>But under the floorboards there is another story, and it is never ordinary. Under the floorboards there is key material, ratchets, tree paths, signature checks, nonces that must never repeat, public keys that look like harmless 32-byte strings and are in fact small pieces of a war against compromise, subpoenas, database leaks, rogue admins, future attackers, human forgetfulness — and now a new participant whose memory is less like a person’s and more like a room full of GPUs that decline to forget on demand.</p>

<p>This is the long walk from “E2EE is that thing Signal does” to “all right, I can actually read the protocol now, and I can also see why putting an LLM in the middle of it is the most interesting threat model the field has acquired in years.”</p>

<p>Not a pitch. Not a product page. Just the mechanics. Just the wires under the wallpaper.</p>

<p>The basic idea is simple enough that it almost feels suspicious. In a normal web app, your message travels under TLS to the server, the server decrypts it, stores plaintext, maybe indexes it, maybe backs it up, maybe hands it to another client later over TLS again. Transport encryption protects you from the guy sniffing packets on bad Wi-Fi. It does not protect you from the operator of the service, from an admin who goes bad, from a compromised database, from a cloud provider with too much visibility, or from legal compulsion. The server is trusted because it has to be. That is the whole architecture.</p>

<p>End-to-end encryption changes the trust boundary. The client encrypts before the server ever sees content. The server stores ciphertext, forwards ciphertext, replicates ciphertext, backs up ciphertext. Other clients decrypt. The server becomes a delivery service, not an interpreter of meaning. It still sees metadata, because metadata is the cigarette smoke that lingers in every room: who talked to whom, when, how often, in what group. But it does not get the text itself.</p>

<p>That sounds clean, and it is clean, but not cheap. Search becomes hard because the server cannot index what it cannot read. Key loss becomes message loss because there is no honest “reset password” button for ciphertext. Group membership becomes a cryptographic event rather than a database update. Adding someone means giving them cryptographic access. Removing someone means rotating future secrets so they cannot follow the next epoch forward.</p>

<p>That is where the old beautiful protocols come back in style. Not as nostalgia. As necessity.</p>

<p>And today? They are not just back. They are shipping. MLS, less than two years after becoming a published RFC, is poised for deployment across Android phones and iPhones thanks to a refreshed RCS specification, finally enabling interoperable encryption between platform vendors. The protocol that used to live in academic slides now lives in the SMS replacement on a billion devices.</p>

<h2 id="the-bricks-before-the-cathedral">The bricks, before the cathedral</h2>

<p>Most modern cryptography is just six or so primitives stacked with discipline. The magic dissolves once you see that. You stop thinking in terms of mysterious “military grade encryption” and start thinking in terms of a few hard tools used correctly.</p>

<p>A hash function such as SHA-256 takes arbitrary input and gives back a fixed 32-byte output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>H(m) -&gt; 32 bytes
</code></pre></div></div>

<p>It is deterministic. It is one-way, in the practical sense. It is collision resistant, meaning you should not be able to find two distinct messages with the same digest. In real protocol design, hashes are not usually used alone for secrecy. They show up as ingredients: in HMAC, in HKDF, in transcript hashes, in integrity checks.</p>

<p>Then comes HMAC, which is what happens when you take a hash and give it a key:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HMAC(k, m) = H((k ⊕ opad) || H((k ⊕ ipad) || m))
</code></pre></div></div>

<p>That funny nested construction exists because the obvious thing, hashing <code class="language-plaintext highlighter-rouge">k || m</code>, is not robust enough. HMAC is the proper way to say: whoever made this authentication tag knew the secret key. It is one of those places where the right construction looks ugly because it has already survived contact with many clever attacks.</p>

<p>HKDF is next. Diffie–Hellman shared secrets are high entropy, but protocol designers do not just jam raw group elements into AES and hope for the best. HKDF turns input keying material into well-separated, uniform subkeys:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PRK   = HMAC(salt, IKM)
OKM_i = HMAC(PRK, OKM_{i-1} || info || i)
</code></pre></div></div>

<p>The first step, extract, normalizes entropy. The second, expand, produces as many bytes as you need and labels them with context through the <code class="language-plaintext highlighter-rouge">info</code> field. One shared secret can safely give you a chain key, a nonce base, a message key, and more, as long as you domain-separate correctly.</p>

<p>Then there is AEAD, typically AES-128-GCM in this setting:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ciphertext, tag = AEAD_enc(key, nonce, plaintext, aad)
plaintext        = AEAD_dec(key, nonce, ciphertext, tag, aad)
</code></pre></div></div>

<p>Authenticated encryption with associated data means one operation gives confidentiality and integrity together. The plaintext is encrypted. The <code class="language-plaintext highlighter-rouge">aad</code> is not encrypted, but it is authenticated, which is often exactly what you want for fields like channel identifiers or epochs. The critical rule with GCM is brutal and absolute: never reuse a nonce with the same key. Not “usually avoid.” Never. Reuse is catastrophic. Protocols that derive nonces carefully are not being fussy. They are staying alive.</p>

<p>For signatures, Ed25519 has become the civilized default. One private signing key. One public verification key. Small keys, small signatures, fast operations, deterministic signing, a hard-to-misuse API:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">public_raw</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">public_key</span><span class="p">.</span><span class="nf">public_bytes</span><span class="p">(</span>
        <span class="n">encoding</span><span class="o">=</span><span class="n">serialization</span><span class="p">.</span><span class="n">Encoding</span><span class="p">.</span><span class="n">Raw</span><span class="p">,</span>
        <span class="nb">format</span><span class="o">=</span><span class="n">serialization</span><span class="p">.</span><span class="n">PublicFormat</span><span class="p">.</span><span class="n">Raw</span><span class="p">,</span>
    <span class="p">)</span>

<span class="k">def</span> <span class="nf">private_raw</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">private_key</span><span class="p">.</span><span class="nf">private_bytes</span><span class="p">(</span>
        <span class="n">encoding</span><span class="o">=</span><span class="n">serialization</span><span class="p">.</span><span class="n">Encoding</span><span class="p">.</span><span class="n">Raw</span><span class="p">,</span>
        <span class="nb">format</span><span class="o">=</span><span class="n">serialization</span><span class="p">.</span><span class="n">PrivateFormat</span><span class="p">.</span><span class="n">Raw</span><span class="p">,</span>
        <span class="n">encryption_algorithm</span><span class="o">=</span><span class="n">serialization</span><span class="p">.</span><span class="nc">NoEncryption</span><span class="p">(),</span>
    <span class="p">)</span>

<span class="nd">@classmethod</span>
<span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">cls</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="sh">"</span><span class="s">IdentityKeyPair</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">priv</span> <span class="o">=</span> <span class="n">Ed25519PrivateKey</span><span class="p">.</span><span class="nf">generate</span><span class="p">()</span>
    <span class="k">return</span> <span class="nf">cls</span><span class="p">(</span><span class="n">private_key</span><span class="o">=</span><span class="n">priv</span><span class="p">,</span> <span class="n">public_key</span><span class="o">=</span><span class="n">priv</span><span class="p">.</span><span class="nf">public_key</span><span class="p">())</span>
</code></pre></div></div>

<p>This is identity. The device signs things with it. The private half stays on the device. The public half can travel.</p>

<p>For key exchange, X25519 gives you elliptic-curve Diffie–Hellman stripped down to something lean and practical. Alice has private scalar <code class="language-plaintext highlighter-rouge">a</code> and public point <code class="language-plaintext highlighter-rouge">aG</code>. Bob has <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">bG</code>. Alice computes <code class="language-plaintext highlighter-rouge">a * (bG)</code>. Bob computes <code class="language-plaintext highlighter-rouge">b * (aG)</code>. Both arrive at the same shared secret, <code class="language-plaintext highlighter-rouge">abG</code>, without ever sending the private scalars over the wire.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@classmethod</span>
<span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">cls</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="sh">"</span><span class="s">InitKeyPair</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">priv</span> <span class="o">=</span> <span class="n">X25519PrivateKey</span><span class="p">.</span><span class="nf">generate</span><span class="p">()</span>
    <span class="k">return</span> <span class="nf">cls</span><span class="p">(</span><span class="n">private_key</span><span class="o">=</span><span class="n">priv</span><span class="p">,</span> <span class="n">public_key</span><span class="o">=</span><span class="n">priv</span><span class="p">.</span><span class="nf">public_key</span><span class="p">())</span>
</code></pre></div></div>

<p>That is your other fundamental tool: not identity, but key agreement.</p>

<p>Six pieces. SHA-256, HMAC, HKDF, AES-GCM, Ed25519, X25519. A strange little family. Enough to build a secure messaging system if you are disciplined, and enough to destroy one if you are not.</p>

<p>In 2026 there is a new generation joining the family on the public-key side — ML-KEM and ML-DSA, the lattice-based replacements — but the symmetric pillars (SHA-256, HMAC, HKDF, AES-GCM) carry over essentially unchanged. Symmetric cryptography is not what the quantum computer is going to break. We will return to this.</p>

<h2 id="the-curve-behind-the-curtain">The curve behind the curtain</h2>

<p>If you keep looking at crypto long enough, you eventually hit the wall of mathematics and discover it is less a wall than a red curtain. Pull it back and the machinery is ugly but intelligible.</p>

<p>Curve25519 lives over a finite field modulo the prime $2^{255} - 19$. In one common form, the curve equation looks like this:</p>

\[y^2 = x^3 + 486662x^2 + x \pmod{2^{255} - 19}\]

<p>Points on this curve form a group under a specially defined addition law. Pick a base point $G$. Multiply it by an integer scalar $k$, and you get another point $kG$. The security assumption is that given $G$ and $kG$, recovering $k$ is computationally infeasible. That is the elliptic-curve discrete logarithm problem.</p>

<p>Everything starts leaning on that hardness assumption.</p>

<p>In X25519, your private key is a scalar and your public key is the corresponding point. Shared secret computation is just scalar multiplication performed from opposite sides. In Ed25519, signatures rely on related algebra. In a simplified form, verification checks a relation like</p>

\[sG = R + H(R, A, m)A\]

<p>where $A$ is the public key, $R$ is an ephemeral point, and $s$ is part of the signature. The beauty is not that it is simple. The beauty is that it is standardized, optimized, and old enough to trust more than anything newly improvised in a repo at 2:30 a.m.</p>

<p>When people say this ecosystem gives roughly 128-bit security, that means the best known attacks still require on the order of $2^{126}$ to $2^{128}$ work, depending on the primitive and attack model. That is why AES-128 is not some “smaller” and therefore weaker embarrassment next to AES-256. It is already absurdly secure in practice. AES-256 is not wrong; it is just often more aesthetic than necessary in this class of systems.</p>

<p>The lattice cousins, ML-KEM and ML-DSA, lean on different math entirely — module learning with errors. The hardness story over there is younger and the keys are larger, but the high-level shape of the API is reassuringly familiar: generate a keypair, encapsulate to a public key, decapsulate with the private key, get a shared secret out the other end. Same drama, different stage.</p>

<h2 id="the-ciphersuite-as-a-sentence">The ciphersuite as a sentence</h2>

<p>Sometimes protocols hide their entire worldview in one string. Consider this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mls-128-dhkemx25519-aes128gcm-sha256-ed25519
</code></pre></div></div>

<p>That is not branding. That is the full menu.</p>

<p>It says the protocol is MLS, Messaging Layer Security. It says the target security level is about 128 bits. It says the KEM side of HPKE uses X25519-based Diffie–Hellman. It says bulk authenticated encryption uses AES-128-GCM. It says the hash foundation is SHA-256. It says signatures are Ed25519.</p>

<p>A good ciphersuite string is like a proper street sign at night. It tells you exactly where you are.</p>

<p>The 2026 menu is longer. Alongside the classical suite, the working draft for post-quantum MLS registers names like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mls-256-dhkemx25519mlkem768-aes256gcm-sha384-ed25519mldsa65
mls-256-dhkemmlkem768-aes256gcm-sha384-mldsa65
</code></pre></div></div>

<p>The first is hybrid. Two key encapsulations stacked, classical and post-quantum, combined so that an attacker has to break both. The second is post-quantum only, no classical safety net, for when you have decided the lattice assumption is good enough on its own. Which one you pick is a real engineering question and a slightly philosophical one. Hybrid is the cautious answer. Pure PQ is the answer for people who think the classical curtain is going to fall and they would rather not be standing behind it.</p>

<h2 id="hpke-the-sealed-envelope-with-an-ephemeral-key-inside">HPKE, the sealed envelope with an ephemeral key inside</h2>

<p>MLS leans heavily on HPKE, Hybrid Public Key Encryption. HPKE is what you use when you want to encrypt to someone’s public key but still end up with symmetric encryption under the hood, because public-key operations are for bootstrapping trust, not for carrying the bulk of your traffic.</p>

<p>The friendly API looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ciphertext, enc = HPKE_Seal(recipient_pubkey, aad, plaintext)
plaintext       = HPKE_Open(recipient_privkey, enc, aad, ciphertext)
</code></pre></div></div>

<p>Underneath, the sender generates a fresh ephemeral X25519 keypair. Call it $(e, eG)$. The sender computes a Diffie–Hellman shared secret using $e$ and the recipient’s public key. HKDF turns that shared secret into an AEAD key and nonce material. The plaintext gets sealed. The recipient uses their private key and the sender’s ephemeral public key <code class="language-plaintext highlighter-rouge">enc</code> to derive the same shared secret and open the message.</p>

<p>That ephemeral key matters. It means a compromise later does not automatically reveal past HPKE-sealed payloads. Forward secrecy begins here, in these deliberately disposable little keys that exist just long enough to pass a secret across the gap and then disappear.</p>

<p>There is a separate IETF draft for post-quantum and hybrid HPKE, which slots ML-KEM into the same shape. Same envelope, different glue. The KEM step gets bigger. The AEAD step does not. The ergonomics of the calling code barely change. That is what good cryptographic abstractions look like — replaceable parts behind a stable seam.</p>

<h2 id="asynchronous-adds-or-why-prekeys-exist">Asynchronous adds, or why prekeys exist</h2>

<p>The first real awkwardness in messaging is this: how do you cryptographically include someone who is not online right now?</p>

<p>The answer, in Signal land and in MLS land alike, is some version of pre-published one-time public material. MLS calls them KeyPackages. Signal called them prekeys. Same basic energy, less mystique.</p>

<p>A device publishes signed bundles containing an identity public key, a fresh X25519 init public key, some metadata such as lifetime and ciphersuite, and a signature over the whole canonicalized object.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bundle_obj</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">schema</span><span class="sh">"</span><span class="p">:</span> <span class="n">KEYPACKAGE_SCHEMA</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">ciphersuite</span><span class="sh">"</span><span class="p">:</span> <span class="n">ciphersuite</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">user_id</span><span class="sh">"</span><span class="p">:</span> <span class="n">user_id</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">device_id</span><span class="sh">"</span><span class="p">:</span> <span class="n">device_id</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">identity_pubkey</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_b64</span><span class="p">(</span><span class="n">identity</span><span class="p">.</span><span class="nf">public_raw</span><span class="p">()),</span>
    <span class="sh">"</span><span class="s">init_pubkey</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_b64</span><span class="p">(</span><span class="n">init_key</span><span class="p">.</span><span class="nf">public_raw</span><span class="p">()),</span>
    <span class="sh">"</span><span class="s">lifetime</span><span class="sh">"</span><span class="p">:</span> <span class="n">lifetime</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">nonce</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_b64</span><span class="p">(</span><span class="n">secrets</span><span class="p">.</span><span class="nf">token_bytes</span><span class="p">(</span><span class="mi">16</span><span class="p">)),</span>
<span class="p">}</span>
<span class="n">bundle_bytes</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span>
    <span class="n">bundle_obj</span><span class="p">,</span>
    <span class="n">separators</span><span class="o">=</span><span class="p">(</span><span class="sh">"</span><span class="s">,</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">:</span><span class="sh">"</span><span class="p">),</span>
    <span class="n">sort_keys</span><span class="o">=</span><span class="bp">True</span>
<span class="p">).</span><span class="nf">encode</span><span class="p">(</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">)</span>

<span class="n">signature</span> <span class="o">=</span> <span class="n">identity</span><span class="p">.</span><span class="n">private_key</span><span class="p">.</span><span class="nf">sign</span><span class="p">(</span><span class="n">bundle_bytes</span><span class="p">)</span>

<span class="k">return</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">ciphersuite</span><span class="sh">"</span><span class="p">:</span> <span class="n">ciphersuite</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">public_bundle</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_b64</span><span class="p">(</span><span class="n">bundle_bytes</span><span class="p">),</span>
    <span class="sh">"</span><span class="s">signature</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_b64</span><span class="p">(</span><span class="n">signature</span><span class="p">),</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The canonical JSON detail looks boring and is not. Signatures are over bytes, not abstract objects. If two implementations serialize the same object differently, verification breaks. So you sort keys, strip whitespace, and commit to one representation. Real MLS implementations use TLS presentation language for this sort of thing, but the principle is the same: canonicalize or die by ambiguity.</p>

<p>The one-shot property also matters. A KeyPackage’s init key is supposed to be consumed once. One Welcome, one use. Burn after reading. Reusing it stretches the blast radius of compromise across multiple onboarding events, which is exactly what you do not want.</p>

<p>Signal’s PQXDH and the post-quantum extensions to MLS preserve this shape. The prekey bundle gets a lattice public key alongside the classical one. The handshake derives a shared secret from both. The code looks more verbose. The mental model survives intact.</p>

<h2 id="why-mls-exists-instead-of-just-do-signal-but-for-groups">Why MLS exists instead of “just do Signal but for groups”</h2>

<p>Pairwise encrypted messaging is manageable when the room is small. In a large group, naïve pairwise state turns ugly fast. If everyone has to maintain separate secure relationships with everyone else, membership changes become expensive and messy. MLS exists because secure groups needed a protocol designed for groups rather than retrofitted from two-party assumptions.</p>

<p>The central idea is TreeKEM. Imagine a binary tree. Leaves are devices. Internal nodes also carry key material. Each device knows the private keys on its path from leaf to root, and the root secret anchors the current group epoch.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>            root
           /    \
         n1      n2
        / \     / \
      L1  L2  L3  L4
</code></pre></div></div>

<p>A member at leaf <code class="language-plaintext highlighter-rouge">L1</code> knows <code class="language-plaintext highlighter-rouge">L1</code>, <code class="language-plaintext highlighter-rouge">n1</code>, and <code class="language-plaintext highlighter-rouge">root</code>. Another member on the same side knows a different leaf secret but the same path upward. Members on the opposite side know their own path. This structure lets the protocol update group secrets in logarithmic rather than quadratic fashion.</p>

<p>When membership changes, a member creates a fresh path from its leaf to the root. New path secrets are derived and corresponding public keys are distributed to the right sibling subtrees using HPKE. Then the epoch advances. New message keys, nonces, and sender state are derived from the new epoch secret.</p>

<p>You can write the spirit of it as:</p>

\[\text{epoch}_{n+1} = \mathrm{KDF}(\text{epoch}_n, \text{commit}, \text{transcripthash})\]

<p>The exact derivation structure is more elaborate, but the important thing is the property: each epoch change injects fresh entropy and re-anchors the group.</p>

<p>That gives you forward secrecy, meaning compromising today’s state does not unlock yesterday’s messages if old secrets were properly deleted. It also gives post-compromise security. If an attacker steals your current secrets but the group continues and a fresh commit lands, the attacker can be pushed back out of future epochs. This is one of the reasons MLS is so compelling. It does not just try to be secure at a frozen instant. It tries to heal.</p>

<p>The 2026 footnote: RFC 9750, the MLS Architecture document, was published in April 2025, codifying the operational guidance that the protocol document RFC 9420 deliberately left out. The GSMA’s Universal Profile 3.0 made MLS the basis for end-to-end encryption in RCS, with Apple committing to support it on Apple Messages, while Matrix has announced its migration as well. What was a paper protocol is now the substrate of mass-market texting between rival platforms. That is, for a standards effort, a fairy-tale ending.</p>

<h2 id="welcomes-removals-and-the-fact-that-crypto-is-not-a-time-machine">Welcomes, removals, and the fact that crypto is not a time machine</h2>

<p>When a new member is added, someone claims one of that member’s KeyPackages, updates the tree, and creates a Welcome message encrypted to the recipient’s init key. The Welcome contains enough information for the new member to enter the current epoch with the right tree context and path secrets.</p>

<p>That Welcome is the quiet doorway. It is how an asynchronous group says, you were absent, but the cryptography left the porch light on.</p>

<p>Removal is more sobering. When someone is removed, the group rotates forward. Their leaf is blanked, the tree updates, the epoch changes, and they cannot derive future keys. But the past remains the past. If they already received old ciphertext and had the right keys at the time, you do not get to reach into their memory and erase it. End-to-end encryption is powerful, but it does not reverse causality.</p>

<p>People often discover this too late. Revocation means no future access. It does not mean retroactive amnesia.</p>

<p>This becomes painfully relevant when the new member is not a person.</p>

<h2 id="the-third-party-in-the-conversation">The third party in the conversation</h2>

<p>End-to-end encryption defines security in terms of who the “ends” are. For decades, the ends were humans, and the threat model was clear: anyone in the middle is suspect. In 2026, a third kind of end has shoved its way into the room. It does not have a face. It does not show up in the member list. It is an LLM, and somebody — maybe the platform, maybe a single member, maybe the user themselves — has plugged it into the conversation.</p>

<p>Once that happens, the trust model the protocol was designed around quietly bends.</p>

<p>Think about what the LLM has to be able to do in order to be useful. To summarize a thread, it must read the thread. To draft a reply, it must read the context. To translate, transcribe, search the chat history, remember preferences, suggest a meeting time — every one of those features requires plaintext on the LLM’s side of the wire. End-to-end encryption was built to keep messages private, but that privacy starts to fray as soon as decrypted content is handed to an AI assistant. Even if the processing happens in a careful environment, the message is no longer constrained to sender and recipient.</p>

<p>This does not break the cryptographic protocol. The protocol still does exactly what it promises. The bytes are sealed in transit, the server still cannot read them, the tree still rotates, the epoch still advances. What breaks is the implicit social contract that “encrypted” meant “only the people in the chat will see this.” When an assistant joins, the set of entities who see plaintext has grown by one. Sometimes that one is on your phone. Sometimes it is in a data center on another continent. Those two cases are not the same, and treating them as the same is how good protocols start producing bad outcomes.</p>

<p>There is also a collective consent problem that has no precedent in the cryptographic literature. If a single participant in a group enables an AI assistant, every other participant’s messages may be processed by a model none of them opted into. The math of MLS does not know what to do with this. No epoch update will save you from a member who is faithfully relaying every commit message into a vendor’s API. Honest behavior at the protocol layer can be perfectly compatible with what feels, at the human layer, like a quiet betrayal.</p>

<p>And then there is the new attack class. Vulnerabilities like EchoLeak demonstrated that AI assistants can be coaxed into leaking sensitive material — a category researchers have started calling LLM scope violation, where the model exposes context it was never supposed to share, based purely on how it interpreted a crafted prompt. This is the new nonce reuse. It does not look like the old attacks. It does not require breaking AES or finding a discrete log. It requires writing a sentence that sounds innocuous and is, in fact, a small key turned in a lock the assistant did not know it was guarding.</p>

<p>The protocol people have a useful instinct here, and it is worth borrowing: declare the threat model explicitly, then design for it. If the LLM is in the trust boundary, say so. If it is not, do not let it touch plaintext. Anything in between — “well, the model is processed in a special place” — is where the interesting security engineering of this decade is going to live.</p>

<h2 id="private-cloud-compute-or-the-new-attestation-ceremony">Private Cloud Compute, or the new attestation ceremony</h2>

<p>The most elaborate answer to the LLM-in-the-middle problem, so far, is to push the model into a hardware-rooted enclave that the platform itself cannot read into.</p>

<p>Apple’s Private Cloud Compute architecture works roughly like this: the device builds a request containing the prompt and inferencing parameters, encrypts it directly to the public keys of specific PCC nodes the device has cryptographically verified, and the data is supposed to be deleted after the response is returned and never available to Apple staff, including those with administrative access. The trust story is no longer “Apple promises.” It is “Apple publishes the binary running on the node, the device attests that it is talking to that binary, and any deviation is detectable.”</p>

<p>That is a meaningful change. It is also not magic. PCC and confidential computing are not the same thing — PCC focuses on hardening the communication path with verifiable software transparency, while confidential computing focuses on encrypting workloads in use within trusted execution environments, defending against malicious operating systems and hypervisors. The two approaches share primitives — TEEs, remote attestation, sealed channels — but they are answering slightly different questions. Whether either of them gives you the same guarantee that classical end-to-end encryption gives is a more subtle conversation than the marketing usually allows.</p>

<p>The broader pattern across the industry in 2026 is something called a private AI cloud. These architectures lean on three primitives — trusted execution environments that hardware-isolate memory from the host, GPU confidential compute (NVIDIA’s offering extends the trust boundary to include the accelerator), and remote attestation that lets clients verify which code is actually running. Anthropic, Google, and Meta all have variants in production or development. The honest framing is that these clouds give real privacy benefits but do not deliver the same kind of mathematical guarantee as end-to-end encryption — users still have to trust hardware vendors, attestation infrastructure, and abuse-monitoring layers.</p>

<p>This matters because the security argument is no longer “we cannot read your data.” It is “we have arranged the world such that, assuming the chip vendor did their job and the attestation chain holds, we should not be able to read it, and you can verify that arrangement before you send it.” The shift from cryptographic impossibility to verified arrangement is enormous, and easy to miss.</p>

<p>There is a strain of thought that calls this a downgrade. There is another that calls it the only practical way to get useful AI without surrendering everything. Both are partly right.</p>

<h2 id="on-device-or-the-small-model-that-doesnt-tell">On-device, or, the small model that doesn’t tell</h2>

<p>The other answer — quieter, less heroic, increasingly viable — is to keep the model on the device.</p>

<p>The economic and regulatory pressure here is genuine. Local LLMs are gaining traction precisely because they sidestep concerns about transmitting sensitive material over the internet, and the on-device AI market is projected to grow into the tens of billions of dollars by 2030. Phones in 2026 routinely run quantized 1B to 3B parameter models well enough to handle summarization, translation, dictation, basic agent workflows, and the small set of tasks that used to require a network round trip. Cross-platform inference SDKs now report sub-50ms time-to-first-token for on-device models, eliminating network latency and defaulting to total privacy.</p>

<p>For the threat model we have been building, this is the cleanest fit. If the model lives on your device, then “the LLM saw it” and “your local app saw it” are the same statement. The tree of trust does not get an extra branch. The bytes do not leave. The Welcome message gets opened, the epoch keys get derived, the plaintext gets handed to a small model that runs on the same silicon as your photo library, and nothing crosses the network that was not already going to.</p>

<p>This is not a complete answer. The on-device models are smaller and dumber than their cloud cousins. Some workloads still demand the bigger brain. The compromise is roughly: do small things locally, and fall back to a verified private cloud only when you have to. That is the architecture quietly emerging in shipped products — Apple Intelligence, Proton’s Lumo, certain enterprise RAG stacks. Lumo’s privacy story explicitly grapples with what end-to-end encryption even means when one of the ends is a language model rather than a person, and tries to encrypt both in transit and at rest while still letting the model do useful work.</p>

<p>The protocol designers do not get to settle this debate for the product designers. But protocol designers can at least insist that the seam between “encrypted message” and “AI feature” be honest. If the assistant runs locally, say so. If it does not, say where it runs and what it is allowed to remember. Anything else is a rendering trick, and rendering tricks tend to age badly.</p>

<h2 id="the-server-as-a-dumb-and-useful-machine">The server as a dumb and useful machine</h2>

<p>One of the most attractive architectural consequences of doing this properly is that the server gets demoted. Not removed, not romanticized, just demoted.</p>

<p>It authenticates users and devices. It enforces access control. It stores blobs. It forwards notifications. It keeps monotonic epoch state so clients do not fork the group history accidentally or maliciously.</p>

<p>A server-side epoch check can look as plain as this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">req</span><span class="p">.</span><span class="n">epoch</span> <span class="o">!=</span> <span class="n">mls</span><span class="p">.</span><span class="n">epoch</span> <span class="o">+</span> <span class="mi">1</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span>
        <span class="n">status_code</span><span class="o">=</span><span class="n">status</span><span class="p">.</span><span class="n">HTTP_409_CONFLICT</span><span class="p">,</span>
        <span class="n">detail</span><span class="o">=</span><span class="sa">f</span><span class="sh">"</span><span class="s">epoch out of order: expected </span><span class="si">{</span><span class="n">mls</span><span class="p">.</span><span class="n">epoch</span> <span class="o">+</span> <span class="mi">1</span><span class="si">}</span><span class="s">, got </span><span class="si">{</span><span class="n">req</span><span class="p">.</span><span class="n">epoch</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>That is almost disappointingly simple, which is a sign of a good separation of concerns. The server should not be doing cryptographic interpretation of runtime group content. It should not need private keys. It should not parse more than it must. It should not become the wise central brain that every future compromise wants to interrogate.</p>

<p>The beauty of opaque blob storage is not elegance alone. It is strategic humility. If later you swap a reference implementation for OpenMLS or AWS’s mls-rs, or move from purely classical suites to hybrid post-quantum ones, the server ideally barely notices. If you decide to add an on-device assistant, the server still does not learn anything new. The server’s ignorance is the system’s strength.</p>

<h2 id="the-threat-model-without-neon-and-lies">The threat model, without neon and lies</h2>

<p>There is always a temptation in security writing to sound apocalyptic on the sales pages and omnipotent in the architecture docs. Better to stay sober.</p>

<p>What this kind of design defends against: passive network attackers, database readers, many classes of server compromise, leaked backups, and some forms of device compromise once the group rotates. Clients verify cryptographic commits, so a malicious admin can censor or destroy availability, but cannot forge valid group evolution undetected.</p>

<p>What it does not defend against: metadata analysis, compromised clients, an attacker who is legitimately added to a group, a future quantum adversary powerful enough to break the classical public-key assumptions underneath X25519 and Ed25519, and — newly important — any AI assistant that has been granted plaintext access to the conversation, regardless of how nicely its inference is wrapped.</p>

<p>Metadata remains a live wound. Even if content is sealed, the delivery service still sees patterns. Presence, read receipts, typing indicators, retention, and logs all become political choices as much as technical ones. Every extra signal you store is another little lantern for a future adversary.</p>

<p>A compromised client is game over for that endpoint. This is a hard truth worth stating plainly. If the software that performs encryption has already been subverted, then “but the protocol is sound” is a eulogy, not a defense. The 2026 corollary is harder still: an LLM-enabled client is a client whose attack surface includes prompt injection, model jailbreaks, and any data flow the assistant can reach. Securing the cryptography buys you nothing against an assistant that is smoothly persuaded to exfiltrate the very chat it just helped you summarize.</p>

<h2 id="the-quantum-ghost-in-the-corner-now-wearing-a-name-tag">The quantum ghost in the corner, now wearing a name tag</h2>

<p>In the last edition of this essay, the quantum threat felt like a long shadow at the end of a hallway. It is closer now, and it has acquired specific names.</p>

<p>NIST has finalized its first post-quantum standards. ML-KEM, the lattice-based KEM previously known as Kyber, was standardized as FIPS 203 — strong security with relatively small keys and ciphertexts of around 1.5 KB, efficient on modest hardware, and the post-quantum half of most hybrid TLS and VPN deployments today. ML-DSA, formerly Dilithium, is its signature counterpart. In 2025 NIST also selected HQC, a code-based KEM, as a backup standard alongside Kyber, with a final standard expected by 2027. Two different mathematical families, in case one of them turns out to have a hidden window.</p>

<p>The migration is no longer hypothetical. AWS has begun rolling out ML-KEM support across services such as KMS, ACM, and Secrets Manager, with the older pre-standard Kyber implementations slated for removal across all AWS endpoints in 2026. The IETF has a working draft for MLS post-quantum ciphersuites that pairs ML-KEM with ML-DSA inside the existing protocol framing. The hybrid HPKE draft does the same for the envelope primitive everyone leans on.</p>

<p>The reason ciphersuite agility was always emphasized: this is exactly the moment it has to pay off. Architectures that hardwired X25519 are now staring down expensive surgery. Architectures that treated the ciphersuite as opaque metadata and let the clients decide are doing migrations as configuration changes.</p>

<p>A future hybrid suite looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mls-128-dhkemx25519hybridmlkem768-aes128gcm-sha256-ed25519
</code></pre></div></div>

<p>The idea is to combine a classical component and a post-quantum KEM. Break both or fail. That is how sane migrations happen: hybrid first, caution always, no messianic declarations that one shiny new primitive has already replaced decades of cryptanalysis.</p>

<p>If your architecture stores the ciphersuite as opaque metadata and leaves cryptographic interpretation to the clients, then supporting such a migration is not a religious conversion. It is a controlled evolution.</p>

<p>That is what good protocol design looks like. Not immortality. Replaceable parts.</p>

<p>The harvest-now-decrypt-later concern, once theoretical, is the operational reason for moving early. If an adversary is recording your ciphertext in 2026 and a cryptographically relevant quantum computer exists in 2036, anything you sent under classical-only crypto today is potentially in their reading queue. The hybrid suites exist to take that bet off the table, retroactively, for the messages you send now.</p>

<h2 id="the-ugly-bits-that-are-not-the-same-as-broken-bits">The ugly bits that are not the same as broken bits</h2>

<p>Every real system has debt. The important distinction is between debt that is operational and debt that is cryptographic.</p>

<p>Race conditions in KeyPackage claims on a lightweight database. In-process pubsub that will not survive multi-worker scale. No finalized key backup and recovery design. Garbage collection of consumed prekeys left for later. An LLM integration that helpfully summarizes the chat into a third-party API without telling users where the bytes ended up. These are flaws, some annoying, some serious, but not all of them are cryptographic in nature.</p>

<p>A system can ship with operational debt and live to improve. A system that ships with nonce reuse, unauthenticated state transitions, or home-rolled key derivation is a crime scene wearing sneakers. A system that ships with an undisclosed AI assistant in the trust boundary is somewhere between the two — not a cryptographic break, but a category of breach that the user could not have consented to because they were not told it existed.</p>

<p>That distinction matters because protocol work attracts a lot of performative purity. Better to be exact. Some compromises are survivable. Others poison the well. And some, increasingly, look survivable in the architecture diagram and become catastrophic the moment you check what the assistant is actually doing with the plaintext.</p>

<h2 id="why-these-old-protocols-still-feel-strange-and-new">Why these old protocols still feel strange and new</h2>

<p>There is a David Lynch quality to good security engineering. The surface looks domesticated. A room, a lamp, a clean UI, a person typing, a friendly assistant offering to summarize the day. But behind the wall there is another room, and behind that room there is machinery, and behind the machinery there is an old mathematics that does not care about your aesthetic preferences, and lately, behind all of that, there is a model with weights large enough to encode an unsettling amount of human writing, humming away on a chip in a building you have never visited.</p>

<p>You send a message and what really happens is a signature binds a canonical object, an ephemeral keypair blooms and dies, HKDF stretches a shared secret into clean material, a nonce is derived with priestly care, a tree path rotates, a transcript hash ratifies continuity, an epoch advances, a server shrugs and forwards bytes it cannot read — and then, optionally, on one device, the bytes are turned back into language and handed to a model that chooses words back. Ordinary life on top, algebra underneath, a speaking animal in a cage at the end.</p>

<p>This is why secure messaging is so easy to misunderstand. It looks like product behavior, but it is protocol behavior. It looks like UX, but under the UX is a chain of assumptions so brittle and exact that one reused nonce or one badly scoped secret can turn the whole palace into vapor. And with the assistant in the room, the brittle exactness extends now to a new question: what is the model allowed to remember, who decided, and how would you ever know.</p>

<p>And yet the result, when done right, is almost poetic in a severe, technical way. A group changes shape and the cryptography changes with it. A stolen key does not mean the attacker owns the future forever. A server becomes less powerful by design. Trust is pushed outward to endpoints and bounded by verifiable math rather than institutional promises. And, in the best versions of the assistant story, the model lives close to the user, the prompt never leaves, the helpful little voice is bounded by the same silicon that holds the keys.</p>

<p>The protocol you like is going to come back in style because the world keeps rediscovering the same hard lesson: if the middle can read everything, then eventually the middle matters too much. And when the middle matters too much, someone always tries to own it. The middle has a new shape now — it can be a database, a delivery service, a side channel, or a 70-billion-parameter model in a rack — but the lesson is unchanged.</p>

<p>So the old names return. Diffie–Hellman. EdDSA. HKDF. AEAD. HPKE. MLS. They do not return as retro chic. They return because the conditions that made them necessary never really left. They were just waiting under the stage lights for everybody else to catch up.</p>

<p>The new names are joining them. ML-KEM. ML-DSA. HQC. Confidential inference. Attested enclaves. On-device models. They are not replacements. They are companions. The cathedral is still being built. The bricks have just gotten a little bigger and a little stranger.</p>

<h2 id="further-reading-for-when-the-curtain-has-lifted">Further reading, for when the curtain has lifted</h2>

<p>If you want the standards behind the machinery, read RFC 9420 for MLS, RFC 9750 for the MLS Architecture, RFC 9180 for HPKE, RFC 7748 for X25519, RFC 8032 for Ed25519, RFC 5869 for HKDF, FIPS 203 for ML-KEM, and FIPS 204 for ML-DSA. For the works in progress, look at the IETF drafts for MLS post-quantum ciphersuites, HPKE post-quantum modes, and the CFRG hybrid KEM design.</p>

<p>If you want the historical road into this territory, read the Signal X3DH and Double Ratchet papers, then the Signal blog post introducing PQXDH. MLS did not appear from nowhere. It is what happens when the industry learns, painfully and repeatedly, that secure groups are not just pairwise messaging with more tabs open.</p>

<p>If you want the new territory of AI-meets-encryption, the NYU and Cornell paper “How to think about end-to-end encryption and AI” is a good entry point, alongside Apple’s Private Cloud Compute write-up and Anthropic’s Confidential Inference via Trusted Virtual Machines report. They will not give you a settled answer. They will give you the right questions, which is more useful at this stage.</p>

<p>And if you take only one practical lesson from all this, let it be this one: do not roll your own crypto. Do not, while you are at it, roll your own AI privacy story either. Even when you do not, even when you stand on standardized primitives and modern protocols and verifiable enclaves, there is still enough to think about to keep the room humming all night.</p>]]></content><author><name>stzifkas</name></author><category term="security" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/the-protocol-you-like.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/the-protocol-you-like.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">We Used to Talk About Tor. Well We’ve Got LLM Agents</title><link href="https://stzifkas.github.io/llm/security/2026/04/14/we-used-to-talk-about-tor-well-weve-got-llm-agents.html" rel="alternate" type="text/html" title="We Used to Talk About Tor. Well We’ve Got LLM Agents" /><published>2026-04-14T00:00:00+00:00</published><updated>2026-04-14T00:00:00+00:00</updated><id>https://stzifkas.github.io/llm/security/2026/04/14/we-used-to-talk-about-tor-well-weve-got-llm-agents</id><content type="html" xml:base="https://stzifkas.github.io/llm/security/2026/04/14/we-used-to-talk-about-tor-well-weve-got-llm-agents.html"><![CDATA[<p><img src="/assets/wbtor.png" alt="We Used to Talk About Tor. Well We’ve Got LLM Agents" /></p>

<p>There was a time when internet privacy debates had a relatively stable shape.</p>

<p>We talked about Tor, VPNs, encrypted email, browser fingerprinting, metadata retention, and traffic analysis. The underlying model was clear enough: a human user interacted with a networked environment, and the primary risk was that this interaction could be observed, recorded, correlated, and ultimately exploited. The goal of security and privacy technologies was therefore to reduce visibility, distribute trust, and make surveillance more expensive or less reliable.</p>

<p>Those concerns remain valid. But they are no longer sufficient to describe the current landscape.</p>

<p>What has changed is not only how data is transmitted or stored, but where action itself takes place. Increasingly, users are not acting alone. They are accompanied by software systems - LLM-based assistants, copilots, and agents - that read across data sources, interpret intent, retrieve context, and, in many cases, take action on their behalf. These systems do not simply protect or expose user activity. They participate in it.</p>

<p>This introduces a qualitatively different problem. The question is no longer only who can observe the user. It is also what can act for the user, what information that system must consume in order to do so, and how its decision-making process can be influenced or subverted.</p>

<p>Tor addressed concealment. Agents introduce delegated authority.</p>

<p>That distinction is not superficial. It alters the level at which security needs to be reasoned about.</p>

<h2 id="from-protecting-communication-to-governing-execution">From protecting communication to governing execution</h2>

<p>The traditional privacy stack focused largely on protecting communication paths. Systems like Tor obscured origin through layered routing; TLS secured content in transit; end-to-end encryption attempted to ensure that even service providers could not access message contents. Anti-tracking tools reduced the ability of platforms to correlate user behavior across contexts.</p>

<p>These mechanisms were designed for a world in which the user initiated discrete actions. The system’s responsibility was to carry or protect those actions, not to originate them.</p>

<p>Agentic systems shift this boundary upward. They are not merely transporting user intent; they are interpreting it, extending it, and, in some cases, generating new actions that the user did not explicitly specify in detail. This moves the security problem away from transport and storage and toward interpretation, planning, and execution.</p>

<p>In practical terms, this means that the integrity of the system no longer depends only on whether data is encrypted or access-controlled, but on whether the system correctly understands what it is supposed to do and whose authority it is operating under.</p>

<h2 id="what-agent-means-in-real-systems">What “agent” means in real systems</h2>

<p>The term “agent” is often used loosely, so it is useful to ground it in actual system design.</p>

<p>In most current implementations, an agent consists of a language model acting as a central planner within a control loop. It receives user input and contextual state, retrieves additional information through search or RAG pipelines, and has access to a set of tools - these might include APIs, file systems, browsers, databases, or code execution environments. Based on the combined context, the model proposes actions, which are executed by the system, and the results are fed back into the loop until some completion condition is reached.</p>

<p>This architecture effectively turns the model into a coordination layer across heterogeneous systems. It is not simply generating text; it is orchestrating operations. The critical point is that the same mechanism used to interpret natural language is now also responsible for selecting actions that have real side effects.</p>

<p>This coupling between interpretation and execution is what creates new risk.</p>

<p>A striking amount of the current ecosystem still builds agents in a way that, from a security perspective, is essentially equivalent to letting the model read everything, decide everything, and call everything. In pseudo-code, the unsafe version looks something like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run_agent</span><span class="p">(</span><span class="n">user_request</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="n">context</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">context</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">user_request</span><span class="p">})</span>

    <span class="n">retrieved_docs</span> <span class="o">=</span> <span class="nf">rag_search</span><span class="p">(</span><span class="n">user_request</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">retrieved_docs</span><span class="p">:</span>
        <span class="n">context</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">system</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">doc</span><span class="p">.</span><span class="n">text</span><span class="p">})</span>

    <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">llm</span><span class="p">.</span><span class="nf">generate</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">tools</span><span class="o">=</span><span class="n">ALL_TOOLS</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">response</span><span class="p">.</span><span class="nb">type</span> <span class="o">==</span> <span class="sh">"</span><span class="s">tool_call</span><span class="sh">"</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="nf">execute_tool</span><span class="p">(</span>
                <span class="n">name</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">tool_name</span><span class="p">,</span>
                <span class="n">args</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">tool_args</span><span class="p">,</span>
            <span class="p">)</span>
            <span class="n">context</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">tool</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">result</span><span class="p">)})</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">content</span>
</code></pre></div></div>

<p>At first glance this looks clean. It is also a compact summary of the problem. Retrieved documents are inserted into the same effective decision space as the user’s request. The model is trusted to decide which tool to call. All tools are available in the same loop. There is no external policy layer, no trust separation, no approval boundary, and no explicit identity scoping. If one of the retrieved documents contains adversarial instructions, or if the model simply infers the wrong next step, the system has no meaningful brake.</p>

<p>This is the architectural equivalent of saying: “Here is a probabilistic parser of ambiguous language. Let it sit in the middle of our infrastructure.”</p>

<h2 id="the-expansion-of-the-attack-surface-into-context">The expansion of the attack surface into context</h2>

<p>Traditional software systems treat input as data that must be validated before use. Agentic systems, by design, ingest large volumes of heterogeneous input and treat it as part of the reasoning process.</p>

<p>This input may include webpages, documents, emails, chat messages, code, logs, and prior outputs from the system itself. Importantly, there is no inherent distinction between “data” and “instructions” in natural language. Once incorporated into the model’s context window, any piece of text can influence subsequent decisions.</p>

<p>This is the essence of prompt injection, but it is better understood as a broader class of semantic attacks. A malicious document does not need to exploit a memory vulnerability if it can alter the model’s understanding of the task. A webpage does not need to execute code if it can persuade the system that a particular action is necessary or authorized.</p>

<p>In classical systems, we work hard to separate code from data. In agentic systems, that separation is blurred by design. The model must interpret meaning across inputs, and meaning in natural language often carries implicit instructions.</p>

<p>This makes context itself an attack surface.</p>

<h2 id="retrieval-as-a-security-boundary">Retrieval as a security boundary</h2>

<p>Retrieval-augmented generation is commonly framed as a technique for improving accuracy by grounding the model in external knowledge. In an agentic setting, however, retrieval becomes a critical security boundary.</p>

<p>When external or semi-trusted content is introduced into the model’s working context, it gains the ability to influence decision-making. If all retrieved content is treated equally, then untrusted sources may acquire the same effective authority as system policies or user instructions.</p>

<p>A robust design therefore needs to treat different classes of input differently. User intent, system policy, structured internal state, and externally retrieved content should not be merged into a single undifferentiated prompt. Each should carry metadata about its origin, trust level, and permissible influence.</p>

<p>Without such separation, the model is left to infer authority relationships from patterns in text, which is not a reliable basis for security-critical decisions.</p>

<p>This is where safer designs begin to look less like chat wrappers and more like security middleware. A more defensible control loop usually has a very different shape:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run_agent</span><span class="p">(</span><span class="n">user_request</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">user_identity</span><span class="p">:</span> <span class="n">Identity</span><span class="p">):</span>
    <span class="n">plan_context</span> <span class="o">=</span> <span class="p">{</span>
        <span class="sh">"</span><span class="s">user_request</span><span class="sh">"</span><span class="p">:</span> <span class="n">user_request</span><span class="p">,</span>
        <span class="sh">"</span><span class="s">trusted_policy</span><span class="sh">"</span><span class="p">:</span> <span class="nf">load_policy_bundle</span><span class="p">(</span><span class="n">user_identity</span><span class="p">),</span>
        <span class="sh">"</span><span class="s">structured_state</span><span class="sh">"</span><span class="p">:</span> <span class="nf">load_structured_state</span><span class="p">(</span><span class="n">user_identity</span><span class="p">),</span>
        <span class="sh">"</span><span class="s">retrieved_untrusted</span><span class="sh">"</span><span class="p">:</span> <span class="nf">retrieve_untrusted_context</span><span class="p">(</span><span class="n">user_request</span><span class="p">),</span>
    <span class="p">}</span>

    <span class="n">proposed_action</span> <span class="o">=</span> <span class="nf">llm_plan</span><span class="p">(</span><span class="n">plan_context</span><span class="p">,</span> <span class="n">tool_catalog</span><span class="o">=</span><span class="n">SAFE_TOOL_SCHEMAS</span><span class="p">)</span>

    <span class="n">decision</span> <span class="o">=</span> <span class="n">policy_engine</span><span class="p">.</span><span class="nf">evaluate</span><span class="p">(</span>
        <span class="n">actor</span><span class="o">=</span><span class="n">user_identity</span><span class="p">,</span>
        <span class="n">action</span><span class="o">=</span><span class="n">proposed_action</span><span class="p">,</span>
        <span class="n">trust_context</span><span class="o">=</span><span class="n">plan_context</span><span class="p">,</span>
    <span class="p">)</span>

    <span class="k">if</span> <span class="ow">not</span> <span class="n">decision</span><span class="p">.</span><span class="n">allowed</span><span class="p">:</span>
        <span class="k">return</span> <span class="nf">deny</span><span class="p">(</span><span class="n">decision</span><span class="p">.</span><span class="n">reason</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">decision</span><span class="p">.</span><span class="n">requires_approval</span><span class="p">:</span>
        <span class="n">approved</span> <span class="o">=</span> <span class="nf">request_human_approval</span><span class="p">(</span>
            <span class="n">actor</span><span class="o">=</span><span class="n">user_identity</span><span class="p">,</span>
            <span class="n">action</span><span class="o">=</span><span class="n">proposed_action</span><span class="p">,</span>
            <span class="n">reason</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">reason</span><span class="p">,</span>
        <span class="p">)</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">approved</span><span class="p">:</span>
            <span class="k">return</span> <span class="sh">"</span><span class="s">Action cancelled.</span><span class="sh">"</span>

    <span class="n">result</span> <span class="o">=</span> <span class="nf">execute_tool_as_principal</span><span class="p">(</span>
        <span class="n">principal</span><span class="o">=</span><span class="n">user_identity</span><span class="p">,</span>
        <span class="n">tool</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">tool_name</span><span class="p">,</span>
        <span class="n">args</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">filtered_args</span><span class="p">,</span>
        <span class="n">scope</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">scope</span><span class="p">,</span>
    <span class="p">)</span>

    <span class="nf">write_audit_log</span><span class="p">(</span>
        <span class="n">actor</span><span class="o">=</span><span class="n">user_identity</span><span class="p">,</span>
        <span class="n">action</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">tool_name</span><span class="p">,</span>
        <span class="n">args</span><span class="o">=</span><span class="n">decision</span><span class="p">.</span><span class="n">filtered_args</span><span class="p">,</span>
        <span class="n">provenance</span><span class="o">=</span><span class="n">plan_context</span><span class="p">,</span>
        <span class="n">result_summary</span><span class="o">=</span><span class="nf">summarize</span><span class="p">(</span><span class="n">result</span><span class="p">),</span>
    <span class="p">)</span>

    <span class="k">return</span> <span class="n">result</span>
</code></pre></div></div>

<p>The difference between these two designs is not stylistic. It is the difference between using an LLM as a helpful component inside a controlled system and using it as the system itself.</p>

<p>This is still only pseudo-code, but it reflects a radically different philosophy. The model proposes; it does not authorize. Retrieved content is not silently merged with policy. Tool access is scoped. Identity is explicit. Arguments can be filtered before execution. High-risk actions can be routed through human approval. The system is designed around the assumption that the model may be manipulated, confused, or simply wrong.</p>

<p>That is the mindset agentic systems require.</p>

<h2 id="tool-use-and-the-materialization-of-errors">Tool use and the materialization of errors</h2>

<p>The introduction of tool use fundamentally changes the impact of model errors.</p>

<p>In a purely conversational system, a hallucination is often limited to incorrect text. In an agentic system, the same misinterpretation can result in a concrete action: an email sent to the wrong recipient, a file deleted, a database query executed, or sensitive data exported.</p>

<p>The design of tools therefore becomes central to system safety. Tools should be narrowly scoped, with well-defined schemas and constrained capabilities. They should enforce least privilege, require explicit confirmation for sensitive operations, and ideally operate in sandboxed environments. Importantly, the system should treat model outputs as proposals rather than authoritative commands.</p>

<p>A secure architecture places policy enforcement outside the model. The model may suggest an action, but a separate control layer should determine whether that action is permitted given the current context, identity, and risk profile.</p>

<h2 id="the-reappearance-of-the-confused-deputy">The reappearance of the confused deputy</h2>

<p>The confused deputy problem provides a useful lens for understanding many of these risks. In that scenario, a system with legitimate authority is tricked into misusing that authority on behalf of an unauthorized party.</p>

<p>Agents are particularly susceptible to this pattern because they aggregate multiple sources of input and operate across multiple systems. They may receive instructions from users, colleagues, documents, and external content, and must continuously decide which signals are authoritative.</p>

<p>If an agent misattributes authority - for example, by treating a statement in a retrieved document as a valid instruction - it may perform actions that appear legitimate from a technical perspective but are semantically unauthorized.</p>

<p>The challenge is that this is not a traditional exploit. It is a failure of interpretation under conditions of ambiguity and adversarial input.</p>

<h2 id="identity-privilege-and-execution-context">Identity, privilege, and execution context</h2>

<p>Another area where agentic systems introduce complexity is identity management.</p>

<p>Actions may be executed under different identities: the user’s account, a service account, an API key, or an authenticated browser session. If these identities are not clearly separated and bound to specific scopes, the system may inadvertently escalate privileges.</p>

<p>For example, an agent might fulfill a request using a backend API with broader access than the user’s own permissions, simply because that path is available. From the system’s perspective, this is efficient. From a security perspective, it violates the principle of least privilege.</p>

<p>Each tool invocation should therefore be explicitly associated with an identity, a scope, and a justification. These bindings should be visible, auditable, and enforceable.</p>

<h2 id="memory-as-a-persistent-risk-surface">Memory as a persistent risk surface</h2>

<p>Memory is often presented as a feature that enhances usability by allowing systems to retain context across interactions. From a security standpoint, however, memory is also a form of persistent state that can accumulate sensitive information, stale assumptions, or adversarial inputs.</p>

<p>Different types of memory - short-term conversational context, task-level state, and long-term user profiles - have different risk profiles, but all require lifecycle management. Systems should define what can be stored, how it is validated, how long it is retained, and how it can be inspected or deleted.</p>

<p>Without such controls, memory can become both a source of leakage and a vector for long-lived manipulation.</p>

<h2 id="natural-language-as-a-control-interface">Natural language as a control interface</h2>

<p>One of the more subtle challenges in agent design is the reliance on natural language as a control interface.</p>

<p>Natural language is inherently ambiguous, context-dependent, and open to interpretation. While this flexibility is what makes it attractive for user interaction, it is also what makes it difficult to use safely for high-authority operations.</p>

<p>In traditional systems, commands are expressed in structured formats with well-defined semantics. In agentic systems, similar levels of authority may be triggered by loosely phrased instructions whose exact meaning depends on context.</p>

<p>This places a significant burden on the system to correctly interpret intent and distinguish between instructions, suggestions, and irrelevant information. It also creates opportunities for adversarial inputs to exploit ambiguity.</p>

<h2 id="the-need-for-external-policy-enforcement">The need for external policy enforcement</h2>

<p>Given these challenges, it is not sufficient to rely on the model itself to enforce all constraints.</p>

<p>A robust agent architecture should include external policy mechanisms that evaluate proposed actions before execution. These mechanisms can enforce rules related to access control, data sensitivity, action scope, and risk thresholds.</p>

<p>This separation ensures that even if the model is misled or makes an incorrect inference, the system as a whole can prevent unsafe actions from being carried out.</p>

<h2 id="a-shift-in-the-core-question">A shift in the core question</h2>

<p>The technologies we built around Tor and related systems addressed a fundamental question: how can users interact with digital systems without exposing themselves unnecessarily to observation and control?</p>

<p>That question remains important. But agentic systems introduce a second, equally important question: how can users retain control over systems that act on their behalf?</p>

<p>This is not merely an extension of the original problem. It is a shift in focus from visibility to authority, from communication to execution, and from protecting data to governing action.</p>

<p>If we fail to recognize this shift, we risk applying the wrong solutions to the wrong layer of the system.</p>

<p>We used to worry about who could see us.</p>

<p>We now need to worry about what can act for us, how it makes decisions, and how easily those decisions can be influenced.</p>

<p>That is a more complex problem, and one that will require more than incremental adjustments to existing security models.</p>

<p>It will require treating agentic systems not as enhanced interfaces, but as intermediaries with real power - systems whose design must be constrained, audited, and governed accordingly.</p>]]></content><author><name>stzifkas</name></author><category term="llm" /><category term="security" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/wbtor.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/wbtor.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Owls Are Not What They Seem</title><link href="https://stzifkas.github.io/meta/2026/04/06/hello-world.html" rel="alternate" type="text/html" title="The Owls Are Not What They Seem" /><published>2026-04-06T00:00:00+00:00</published><updated>2026-04-06T00:00:00+00:00</updated><id>https://stzifkas.github.io/meta/2026/04/06/hello-world</id><content type="html" xml:base="https://stzifkas.github.io/meta/2026/04/06/hello-world.html"><![CDATA[<p><img src="/assets/owls-are-not-what-they-seem.png" alt="The Owls Are Not What They Seem" /></p>

<p>First post. The blog is called Fire Walk With Middleware. That should tell you enough about the tone.</p>

<p>Writing about software, LLMs, and whatever I’m currently breaking or building. No schedule, no niche.</p>

<p>More soon.</p>]]></content><author><name>stzifkas</name></author><category term="meta" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://stzifkas.github.io/assets/owls-are-not-what-they-seem.png" /><media:content medium="image" url="https://stzifkas.github.io/assets/owls-are-not-what-they-seem.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>