<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Harrison Guo</title>
    <description>The latest articles on DEV Community by Harrison Guo (@harrisonsec).</description>
    <link>https://dev.to/harrisonsec</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3809272%2F593698c5-7201-4bb0-898e-055cdbc0a2d2.png</url>
      <title>DEV Community: Harrison Guo</title>
      <link>https://dev.to/harrisonsec</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harrisonsec"/>
    <language>en</language>
    <item>
      <title>Same Model, 13.3% to 38.3%</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:05:35 +0000</pubDate>
      <link>https://dev.to/harrisonsec/same-model-133-to-383-gpc</link>
      <guid>https://dev.to/harrisonsec/same-model-133-to-383-gpc</guid>
      <description>&lt;p&gt;Two API settings. Same model. Same benchmark. Same task set.&lt;/p&gt;

&lt;p&gt;13.3% to 38.3%, using one sixth the output tokens.&lt;/p&gt;

&lt;p&gt;OpenAI published that result about GPT-5.6 Sol on ARC-AGI-3, and it is the cleanest natural experiment the field has produced on a question I have been arguing from first principles for a year. Nothing about the model changed. Everything that changed was around it.&lt;/p&gt;

&lt;p&gt;The score is Relative Human Action Efficiency, not a pass rate, and OpenAI estimates the average human tester at 48% on the same set. So the model went from 34.7 RHAE points behind a human to 9.7 behind, and the entire move came from configuration. By my arithmetic that is about 72% of a gap people had been attributing to the model.&lt;/p&gt;

&lt;p&gt;There is a blunter version of the same fact. On the public leaderboard for one of these games, no frontier model gets past the first level. With the reconfigured harness, GPT-5.6 Sol solves all six.&lt;/p&gt;

&lt;p&gt;Everything that moved is in the right-hand column, and none of it is the model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;official harness&lt;/th&gt;
&lt;th&gt;retained reasoning + compaction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;model&lt;/td&gt;
&lt;td&gt;GPT-5.6 Sol&lt;/td&gt;
&lt;td&gt;GPT-5.6 Sol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;score (RHAE, public set)&lt;/td&gt;
&lt;td&gt;13.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;output tokens per game&lt;/td&gt;
&lt;td&gt;6x&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reasoning between turns&lt;/td&gt;
&lt;td&gt;discarded after every action&lt;/td&gt;
&lt;td&gt;retained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at the context limit&lt;/td&gt;
&lt;td&gt;rolling truncation, 175,000 chars&lt;/td&gt;
&lt;td&gt;compaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human tester average&lt;/td&gt;
&lt;td&gt;48%&lt;/td&gt;
&lt;td&gt;48%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the official harness was doing
&lt;/h2&gt;

&lt;p&gt;This is the part worth sitting with, because it is not exotic. It is the most natural way to write an agent loop.&lt;/p&gt;

&lt;p&gt;The benchmark's harness discarded the model's private reasoning after every move, and dropped earlier actions as the context filled up.&lt;/p&gt;

&lt;p&gt;The truncation had a specific number attached: once the conversation exceeded 175,000 characters, the oldest messages were dropped.&lt;/p&gt;

&lt;p&gt;Both of those are the default behaviour of almost every agent framework I have read. Reasoning tokens are expensive, they are not the answer, and the API returns them as a separate thing you have to deliberately keep. Dropping them feels like hygiene. Truncating the oldest turns when context fills is the two-line version of context management and it is what you write first.&lt;/p&gt;

&lt;p&gt;The consequence, in OpenAI's framing, is that the model had to work out the game from scratch on every turn.&lt;/p&gt;

&lt;p&gt;Think about what that does to a task where the whole point is learning rules from interaction. The model plays a move, forms a theory about the rules, and the theory is deleted. Next turn it sees the board and its own past actions, but not why it took them. So it re-derives. It burns output tokens rebuilding a theory it already had, arrives somewhere slightly different, and acts on that.&lt;/p&gt;

&lt;p&gt;That is not a model that cannot learn rules. That is a model with anterograde amnesia, being scored on rule learning.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffaq3x3v5gd8kbypuc4x6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffaq3x3v5gd8kbypuc4x6.webp" alt="The same model in two harnesses" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two loops differ by one arrow. The expensive one is the loop that has to rebuild&lt;br&gt;
its own premise on every pass.&lt;/p&gt;

&lt;p&gt;ARC had a reason for the generic harness, and it is a good one. A simple harness makes model shortcomings more visible, and it makes comparisons between models fairer. Commercial developers tune a harness to each model's features and quirks, which is exactly the thing a benchmark is trying to factor out.&lt;/p&gt;

&lt;p&gt;That reasoning is sound and it still produced a broken measurement. Neutrality is not the absence of assumptions. A harness that discards reasoning has taken a position on whether reasoning should persist, and it happens to be the position that penalises models trained to think across turns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two settings
&lt;/h2&gt;

&lt;p&gt;Retain the reasoning across turns. Compact the history instead of truncating it.&lt;/p&gt;

&lt;p&gt;Neither is clever. Neither required a new model, a new prompt, or a new scaffold. They are settings, and OpenAI's point in publishing was that they are the same settings already running in ChatGPT and Codex. The production harness had them on. The benchmark harness did not.&lt;/p&gt;

&lt;p&gt;The token result is the one I keep returning to. Six times fewer output tokens, while nearly tripling the score.&lt;/p&gt;

&lt;p&gt;We have all internalised a trade here: more careful reasoning costs more tokens, and quality is something you buy. This inverts it. The expensive configuration was expensive &lt;em&gt;because&lt;/em&gt; it was worse. Every discarded chain of reasoning had to be regenerated, and regeneration is output tokens at output prices. The waste and the failure were the same event.&lt;/p&gt;

&lt;p&gt;OpenAI describes the mechanism plainly: with reasoning retained, the model spent less time thinking before each action, because it no longer had to interpret the game from scratch every turn. Less thinking, better play. That only sounds paradoxical if you were counting the re-derivation as thinking.&lt;/p&gt;

&lt;p&gt;I made the general version of this argument in &lt;a href="https://harrisonsec.com/blog/your-ai-bill-is-a-distributed-systems-problem/" rel="noopener noreferrer"&gt;Your AI Bill Is a Distributed Systems Problem&lt;/a&gt;: cost in agent systems is dominated by repeated work, not by unit price. This is that thesis with a controlled experiment attached, run by the model vendor, on a public benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the score was never about the model
&lt;/h2&gt;

&lt;p&gt;OpenAI said the quiet part in their own writeup: an evaluation does not measure the model in isolation. It also measures the API configuration, the harness design, and how the prompt was rendered.&lt;/p&gt;

&lt;p&gt;Their exact words: benchmarks rarely measure AI models in isolation, they also measure less visible choices about API settings, harness design, and prompting.&lt;/p&gt;

&lt;p&gt;Then the line I keep coming back to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This isn't the first time we've been surprised by low scores on a public benchmark and then discovered that the eval runner was using a generic harness that dropped reasoning messages.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not the first time. So there are published numbers, still in circulation, still being cited, that measure a harness defect and are read as a fact about a model. Nobody is going back to correct those.&lt;/p&gt;

&lt;p&gt;That is an unusual thing for a lab to publish, and it is correct.&lt;/p&gt;

&lt;p&gt;Every agentic benchmark number you have read is a measurement of a triple. Model, harness, configuration. The number is real. The attribution is where it goes wrong, and the attribution is the entire reason anyone reads the number.&lt;/p&gt;

&lt;p&gt;This is the exact failure I wrote about in &lt;a href="https://harrisonsec.com/blog/evidence-that-isnt-evidence/" rel="noopener noreferrer"&gt;The Log Printed Exactly What I Wanted&lt;/a&gt;. A true signal, correctly produced, borrowed for a claim it does not support. 13.3% was a true fact about a system. It was read as a fact about a model. Those are different objects, and the difference here is 25 points.&lt;/p&gt;

&lt;p&gt;It is also the &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;wrong ruler&lt;/a&gt; at scale. The benchmark ran. It produced a number. The number was reproducible. None of that made it a measurement of the thing everyone thought was being measured, and the fact that it ran cleanly is exactly why nobody checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable follow-on
&lt;/h2&gt;

&lt;p&gt;If a generic harness cost one model 25 points, then every cross-model comparison run through a single fixed harness is suspect in a specific way.&lt;/p&gt;

&lt;p&gt;A harness is not neutral. It makes assumptions: about whether reasoning persists, about how history is trimmed, about tool-call formatting, about how much of the transcript survives. Those assumptions suit some models and penalise others. Holding the harness constant across models feels like the rigorous choice. It is actually a choice to measure each model's fit to one particular set of assumptions.&lt;/p&gt;

&lt;p&gt;So when a leaderboard says model A beats model B on an agentic task, the defensible reading is that the pair (A, harness) beat the pair (B, harness). Sometimes that is what you want to know, because you are going to run that harness. Usually it is presented as something else.&lt;/p&gt;

&lt;p&gt;I do not think this makes benchmarks useless. I think it makes the harness a required disclosure, the way a benchmark suite already discloses temperature and prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this says about the three harnesses
&lt;/h2&gt;

&lt;p&gt;I have been reading Codex and Pi against Claude Code in this series, and this result reframes the thing they disagree about most.&lt;/p&gt;

&lt;p&gt;Retained reasoning is a state ownership question. Between two turns, somebody has to hold the model's intermediate work. If the harness drops it, the model regenerates it. If the harness keeps it, the harness now owns a piece of state it has to version, serialise, compact and restore.&lt;/p&gt;

&lt;p&gt;That is not a small commitment, which is why the cheap harness drops it. It is also, on this evidence, 25 points.&lt;/p&gt;

&lt;p&gt;Compaction versus truncation is the same shape. Truncation is free and lossy in the worst possible way, since it deletes the oldest context, which is usually where the task was defined. Compaction costs an extra model call and a summarisation strategy, and it keeps the definition.&lt;/p&gt;

&lt;p&gt;Both settings are cases of the harness paying a real engineering cost to avoid making the model redo work. That is the whole thesis of &lt;a href="https://harrisonsec.com/blog/ai-agent-90-percent-problem/" rel="noopener noreferrer"&gt;The 90% Problem&lt;/a&gt;, and it is why the three harnesses all spend between 794 and 1,729 lines on a five-step loop. The lines are where the redoing gets prevented.&lt;/p&gt;

&lt;p&gt;It also lands squarely on &lt;a href="https://harrisonsec.com/blog/agent-memory-is-a-cache-coherence-problem/" rel="noopener noreferrer"&gt;agent memory as a cache coherence problem&lt;/a&gt;. Discarded reasoning is a cache with a hit rate of zero. The model recomputes on every access, correctly, and pays for it every time. Nothing is broken. It is just cold, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your own stack
&lt;/h2&gt;

&lt;p&gt;Two questions, and both have answers you can find today.&lt;/p&gt;

&lt;p&gt;Does your framework persist the model's reasoning between turns, or drop it? Many drop it, few say so, and it is usually one field. If you are running a multi-turn task where the model builds understanding over time, this is the first thing to look at.&lt;/p&gt;

&lt;p&gt;Does your context management compact or truncate? If the answer is that you slice off the oldest messages when you approach the limit, you are running the configuration that scored 13.3%.&lt;/p&gt;

&lt;p&gt;Neither question is about your model. That is the point. The most expensive variable in your agent system is one nobody is benchmarking, and it is sitting in your config file.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Figures and quotations are from OpenAI's own writeup, "How enabling two settings tripled our scores on the ARC-AGI-3 benchmark": GPT-5.6 Sol on the public task set, 13.3% under the official harness against 38.3% with retained reasoning and compaction, output tokens cut 6x, rolling truncation at 175,000 characters, estimated human tester average 48%, scores measured as Relative Human Action Efficiency. The 72% figure is my own arithmetic on the gap to that human baseline and is not OpenAI's claim.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>benchmarking</category>
      <category>llm</category>
    </item>
    <item>
      <title>eBPF on Linux — kprobe vs fentry: Hooking Internals &amp; What Production Observability Misses</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 16:50:48 +0000</pubDate>
      <link>https://dev.to/harrisonsec/ebpf-on-linux-kprobe-vs-fentry-hooking-internals-what-production-observability-misses-2ohb</link>
      <guid>https://dev.to/harrisonsec/ebpf-on-linux-kprobe-vs-fentry-hooking-internals-what-production-observability-misses-2ohb</guid>
      <description>&lt;p&gt;There are two common ways to attach an eBPF program to a kernel function: &lt;code&gt;kprobe&lt;/code&gt; and &lt;code&gt;fentry&lt;/code&gt;. Most tutorials treat them as interchangeable — pick one, attach, read your data. They are not interchangeable. They install into the kernel by different mechanisms, they hand you the function's arguments in different forms, and on a hot path they cost different amounts of CPU per call. Get the choice wrong on a function that fires a million times a second and you have added measurable overhead to every request on the box.&lt;/p&gt;

&lt;p&gt;This post walks the difference at the source level. The companion video disassembles both attachment paths so you can see exactly where the hook lands in machine code; the code here is from &lt;a href="https://github.com/harrison001/SentinelEdge" rel="noopener noreferrer"&gt;SentinelEdge&lt;/a&gt;, an eBPF project of mine that hooks the kernel with &lt;code&gt;kprobe&lt;/code&gt; across 13 attach points, which makes it a useful place to talk honestly about when I'd reach for &lt;code&gt;fentry&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pHlr7MzMJ3w" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;kprobe&lt;/strong&gt; attaches by patching the target instruction. Classically that is a breakpoint (&lt;code&gt;int3&lt;/code&gt; on x86) that traps into the kprobe machinery, runs your program, then resumes the displaced instruction. It works almost anywhere and needs nothing special from the kernel build. You receive a raw &lt;code&gt;struct pt_regs *&lt;/code&gt; and dig the arguments out of registers yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fentry&lt;/strong&gt; attaches to the function's &lt;code&gt;__fentry__&lt;/code&gt; site — the call slot the compiler already left at the top of every traceable function for ftrace — through a generated BPF trampoline. No exception, closer to the cost of a plain call. You receive the arguments already typed, resolved from BTF. It needs a modern kernel (≥ 5.5) built with BTF, and the function has to be ftrace-attachable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same observability. Different install, different per-call cost, different failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The kprobe way, in real code
&lt;/h2&gt;

&lt;p&gt;Here is a live &lt;code&gt;kprobe&lt;/code&gt; from SentinelEdge, hooking &lt;code&gt;do_mmap&lt;/code&gt; to watch memory-mapping activity per process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;SEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kprobe/do_mmap"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;trace_mmap_detailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;pt_regs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;__u32&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bpf_get_current_pid_tgid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;__u64&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PT_REGS_PARM1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;__u64&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PT_REGS_PARM2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;__u32&lt;/span&gt; &lt;span class="n"&gt;prot&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PT_REGS_PARM3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;__u32&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PT_REGS_PARM4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;fs_event&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bpf_ringbuf_reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;fs_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bpf_ktime_get_ns&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// MMAP&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;bpf_ringbuf_submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what the context gives you: &lt;code&gt;struct pt_regs *ctx&lt;/code&gt;, the raw register file at the moment of the trap. There is no &lt;code&gt;do_mmap&lt;/code&gt; prototype in sight. You recover the arguments with &lt;code&gt;PT_REGS_PARM1..N&lt;/code&gt;, which expand to the architecture's calling-convention registers (&lt;code&gt;rdi&lt;/code&gt;, &lt;code&gt;rsi&lt;/code&gt;, &lt;code&gt;rdx&lt;/code&gt;, &lt;code&gt;rcx&lt;/code&gt;, … on x86-64). Nothing checks that &lt;code&gt;PARM3&lt;/code&gt; is really &lt;code&gt;prot&lt;/code&gt;. If the kernel's &lt;code&gt;do_mmap&lt;/code&gt; signature shifts between versions — and it has — your offsets keep compiling and keep returning a number, just the wrong one. The breakage is silent.&lt;/p&gt;

&lt;p&gt;That rawness is also the strength. A &lt;code&gt;kprobe&lt;/code&gt; does not care about the function's type information and, in its classic form, does not even need the function to be specially compiled. It patches bytes. That is why it attaches to almost anything and runs on kernels that predate the typed tracing infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fentry way
&lt;/h2&gt;

&lt;p&gt;The same hook as an &lt;code&gt;fentry&lt;/code&gt; program looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;SEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fentry/do_mmap"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;BPF_PROG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_mmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;prot&lt;/span&gt; &lt;span class="cm"&gt;/* … per the kernel prototype */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;__u32&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bpf_get_current_pid_tgid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// addr, len, prot arrive already typed — no PT_REGS_PARMn&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BPF_PROG&lt;/code&gt; is a libbpf macro that unpacks the trampoline's argument array into named, typed parameters that mirror the kernel's real prototype, with the types resolved from BTF. You stop reading registers by position and start reading arguments by name. If the signature you wrote does not match the kernel's, it fails at load time against BTF, instead of silently handing you a wrong register at run time. That single property — a mismatch becomes a load error rather than corrupt data — is most of why &lt;code&gt;fentry&lt;/code&gt; is the safer default on any kernel new enough to offer it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fexit&lt;/code&gt; is the same idea for function return: it fires on the way out and gives you the arguments &lt;em&gt;and&lt;/em&gt; the return value in one program, which a &lt;code&gt;kretprobe&lt;/code&gt; cannot do without pairing it to an entry probe and stashing state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the cost comes from
&lt;/h2&gt;

&lt;p&gt;The overhead difference is not a tuning constant; it falls out of the two mechanisms.&lt;/p&gt;

&lt;p&gt;A classic &lt;code&gt;kprobe&lt;/code&gt; fires through an exception. The CPU hits the patched breakpoint, traps, the kernel walks its int3 path into the kprobe handler, runs your program, single-steps or emulates the instruction it displaced, and returns. An exception round trip is one of the more expensive things you can put on a hot path.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;fentry&lt;/code&gt; program fires through a call. The trampoline saves the registers it needs, calls your program, restores, and continues into the real function. There is no trap, no single-step. It is close to the cost of an ordinary indirect call.&lt;/p&gt;

&lt;p&gt;One honest nuance the tutorials skip: on a modern kernel, a &lt;code&gt;kprobe&lt;/code&gt; placed at a function's entry can be promoted onto the same ftrace call site fentry uses (&lt;code&gt;KPROBES_ON_FTRACE&lt;/code&gt;) or jump-optimized, which narrows the gap considerably. So "kprobe always means an int3 trap" is not strictly true anymore. But the ceiling still favors &lt;code&gt;fentry&lt;/code&gt;: it is purpose-built for the entry hook, it is typed, and it never falls back to an exception. When you need the lowest, most predictable per-call cost, it is the one to reach for. The video takes both attachment paths apart in a disassembler if you want to see the instruction-level difference rather than take my word for the shape of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need for fentry (and what breaks)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fentry&lt;/code&gt; buys you type safety and low overhead by leaning on infrastructure that older or stripped-down kernels do not have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kernel ≥ 5.5&lt;/strong&gt; for the BPF tracing (&lt;code&gt;fentry&lt;/code&gt;/&lt;code&gt;fexit&lt;/code&gt;) program types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BTF in the kernel&lt;/strong&gt; (&lt;code&gt;CONFIG_DEBUG_INFO_BTF=y&lt;/code&gt;, surfaced at &lt;code&gt;/sys/kernel/btf/vmlinux&lt;/code&gt;). No BTF, no typed attach — this is the usual reason &lt;code&gt;fentry&lt;/code&gt; "mysteriously" refuses to load on a machine where &lt;code&gt;kprobe&lt;/code&gt; is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A function the tracer can reach.&lt;/strong&gt; Anything inlined, marked &lt;code&gt;notrace&lt;/code&gt;, or otherwise missing its &lt;code&gt;__fentry__&lt;/code&gt; site is not attachable. Static/inlined helpers fall into this gap; &lt;code&gt;kprobe&lt;/code&gt; on an address can sometimes still reach them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list is the real decision tree. &lt;code&gt;kprobe&lt;/code&gt; is the tool that works when the target or the kernel does not meet those conditions. &lt;code&gt;fentry&lt;/code&gt; is the tool that wins when they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What production observability tutorials leave out
&lt;/h2&gt;

&lt;p&gt;The standard eBPF walkthrough shows you the attach and the read. It almost never shows the three things that decide whether your tracer survives contact with production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How the probe installs&lt;/strong&gt;, because that is what sets the per-call cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What each call actually costs&lt;/strong&gt;, because you are about to multiply it by the call frequency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The BTF and kernel-version constraints&lt;/strong&gt;, because they decide whether your program loads at all on the fleet you are targeting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The multiplication is the part that bites. Hook something cold — module load, process exit — and the install mechanism barely matters; SentinelEdge uses &lt;code&gt;kprobe&lt;/code&gt; on exactly those kinds of events (&lt;code&gt;do_exit&lt;/code&gt;, &lt;code&gt;init_module&lt;/code&gt;, &lt;code&gt;do_mount&lt;/code&gt;) and the overhead is noise. Hook something on the request path — &lt;code&gt;recvmsg&lt;/code&gt; on a service doing 100K requests a second — and the same per-call gap you could ignore becomes a percentage of the machine. That is the whole game: not "is eBPF fast," but "is &lt;em&gt;this hook&lt;/em&gt; cheap enough at &lt;em&gt;this frequency&lt;/em&gt;."&lt;/p&gt;

&lt;p&gt;It shows up sharply in AI infrastructure, where eBPF is often the only way to see real syscall latency during inference without changing the serving code — network and file I/O, driver entry points, the per-token streaming path, container-boundary cost in multi-tenant serving. Those are hot paths by definition. Put a trap-based hook on one and your tail latency tells the story at 3am; put a trampoline-based one and you keep the visibility for a cost you can round off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually hook it, and when I'd switch
&lt;/h2&gt;

&lt;p&gt;I'll be straight about the code you'll find in SentinelEdge: it hooks with &lt;code&gt;kprobe&lt;/code&gt; and tracepoints, not &lt;code&gt;fentry&lt;/code&gt;. Thirteen &lt;code&gt;kprobe&lt;/code&gt; attach points — &lt;code&gt;vfs_open&lt;/code&gt;, &lt;code&gt;do_mmap&lt;/code&gt;, &lt;code&gt;tcp_connect&lt;/code&gt;, &lt;code&gt;do_exit&lt;/code&gt;, &lt;code&gt;kmem_cache_alloc&lt;/code&gt;, and so on — plus tracepoints for the syscall entries. That was the right call for what the project is: a breadth-first map of kernel activity across a wide set of functions, much of it cold or medium-frequency, aimed at running on a range of kernels without assuming BTF. &lt;code&gt;kprobe&lt;/code&gt; attaches everywhere and asks nothing of the build.&lt;/p&gt;

&lt;p&gt;The moment I'd move a specific hook to &lt;code&gt;fentry&lt;/code&gt; is when it lands on a genuine hot path and I control the kernel it runs on — a modern BTF-enabled build. At that point the typed arguments remove a class of silent version-drift bugs, and the trampoline removes the per-call tax that a request-path frequency would otherwise amplify. It is a per-hook decision, not a project-wide religion.&lt;/p&gt;

&lt;p&gt;(The eBPF programs and the ring-buffer data path in SentinelEdge are real and runnable; the distributed pieces are design sketches. The kernel-hooking code is the part worth reading.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing, quickly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;kprobe&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;fentry&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Installs by&lt;/td&gt;
&lt;td&gt;patching the instruction (classically &lt;code&gt;int3&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;BPF trampoline on the &lt;code&gt;__fentry__&lt;/code&gt; site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-call cost&lt;/td&gt;
&lt;td&gt;higher (exception path; less on optimized kernels)&lt;/td&gt;
&lt;td&gt;lower (call, no trap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arguments&lt;/td&gt;
&lt;td&gt;raw &lt;code&gt;pt_regs&lt;/code&gt;, &lt;code&gt;PT_REGS_PARMn&lt;/code&gt;, untyped&lt;/td&gt;
&lt;td&gt;typed via BTF (&lt;code&gt;BPF_PROG&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs BTF&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Min kernel&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;5.5+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reaches inlined/&lt;code&gt;notrace&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;sometimes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Return values&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kretprobe&lt;/code&gt; (paired)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fexit&lt;/code&gt; (args + retval in one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;old kernels, broad coverage, cold hooks&lt;/td&gt;
&lt;td&gt;hot paths, typed access, controlled modern kernels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one-line version: &lt;strong&gt;&lt;code&gt;fentry&lt;/code&gt; is the default on any kernel new enough to give it to you, and &lt;code&gt;kprobe&lt;/code&gt; is what you use when the kernel or the target won't.&lt;/strong&gt; The mistake isn't picking one — it's picking without knowing that a hook on a hot function just made the choice a line item on your CPU budget.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code above is from &lt;a href="https://github.com/harrison001/SentinelEdge" rel="noopener noreferrer"&gt;SentinelEdge&lt;/a&gt;. The &lt;a href="https://www.youtube.com/watch?v=pHlr7MzMJ3w" rel="noopener noreferrer"&gt;companion video&lt;/a&gt; disassembles both attachment paths at the instruction level.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ebpf</category>
      <category>linux</category>
      <category>kernel</category>
      <category>observability</category>
    </item>
    <item>
      <title>The Approval Prompt Is Not the Sandbox</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:33:10 +0000</pubDate>
      <link>https://dev.to/harrisonsec/the-approval-prompt-is-not-the-sandbox-1i80</link>
      <guid>https://dev.to/harrisonsec/the-approval-prompt-is-not-the-sandbox-1i80</guid>
      <description>&lt;p&gt;Codex ships 43,591 lines of sandboxing code across four platform backends.&lt;/p&gt;

&lt;p&gt;Pi ships zero, and puts it in the README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both of those are reasonable engineering positions. I am not going to argue that one team is careless. What I want to look at is the space between them, because that is where most people are actually operating, and it is the one position that is not reasonable: running an agent that shows you a prompt, and concluding from the prompt that something is enforcing a boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, not one
&lt;/h2&gt;

&lt;p&gt;The single most useful thing in the Codex source is that it refuses to collapse permission into one setting. There are two enums, and they are independent.&lt;/p&gt;

&lt;p&gt;Enforcement, from &lt;code&gt;codex-rs/protocol/src/config_types.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;SandboxMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ReadOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// default&lt;/span&gt;
    &lt;span class="n"&gt;WorkspaceWrite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DangerFullAccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Escalation, from &lt;code&gt;codex-rs/protocol/src/protocol.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AskForApproval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;UnlessTrusted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OnRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// default&lt;/span&gt;
    &lt;span class="nf"&gt;Granular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GranularApprovalConfig&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default pairing is &lt;code&gt;ReadOnly&lt;/code&gt; plus &lt;code&gt;OnRequest&lt;/code&gt;. The agent cannot write anything until something changes, and the model may ask when it wants more.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Granular&lt;/code&gt; variant is worth reading closely, because it is a permission system that has been through contact with reality. It is five separate booleans: &lt;code&gt;sandbox_approval&lt;/code&gt;, &lt;code&gt;rules&lt;/code&gt;, &lt;code&gt;skill_approval&lt;/code&gt;, &lt;code&gt;request_permissions&lt;/code&gt;, &lt;code&gt;mcp_elicitations&lt;/code&gt;. Five different things can ask a human for permission, and you can switch each of them off independently. When a field is false the request is auto-rejected rather than shown, which is the correct default for an unattended run and the wrong one for an interactive session.&lt;/p&gt;

&lt;p&gt;Now hold those two axes apart. Four combinations, and they are four different systems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;approvals on&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;approvals off (&lt;code&gt;Never&lt;/code&gt;)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sandbox on&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the normal case. The OS refuses; a human can grant an exception.&lt;/td&gt;
&lt;td&gt;CI. Nothing is asked, anything outside the box fails, and the failure goes back to the model instead of to a person. Codex's comment on &lt;code&gt;Never&lt;/code&gt; says exactly this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sandbox off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;where most people actually are.&lt;/strong&gt; Every boundary in the system is a human reading a command string.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DangerFullAccess&lt;/code&gt; plus &lt;code&gt;Never&lt;/code&gt;. Honest, at least. This is what a container is for.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bottom-left cell is the one I care about, and it is not a bug in anyone's code. It is a reading error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the approval prompt actually measures
&lt;/h2&gt;

&lt;p&gt;An approval dialog shows you a command. It does not show you what the command reaches.&lt;/p&gt;

&lt;p&gt;Approving &lt;code&gt;npm test&lt;/code&gt; approves the project's test script, plus whatever that script shells out to, plus any lifecycle hook in the dependency tree. Approving &lt;code&gt;make&lt;/code&gt; approves a Makefile you did not write. Approving a Python script approves its imports. The string in the dialog is a handle on an arbitrarily large graph, and the graph is what executes.&lt;/p&gt;

&lt;p&gt;This is the &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;wrong ruler&lt;/a&gt; with a UI. The prompt is not lying. It truthfully reports the command the model proposed. The problem is what people read off it, which is "this operation has been reviewed and bounded," a claim the prompt never made and cannot support.&lt;/p&gt;

&lt;p&gt;An approval is a statement of intent. A sandbox is a statement about capability. Intent without capability limits gives you a system whose entire safety property is that a tired human parses shell correctly at the end of a long session.&lt;/p&gt;

&lt;p&gt;Codex's design makes the distinction structural. Two enums, and you have to set both. That alone is worth stealing even if you never run Codex.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 43,591 lines of enforcement looks like
&lt;/h2&gt;

&lt;p&gt;Here is what enforcement costs when a team actually means it. The sandbox surface of the Codex repository, tests included, measured on 2026-09-01:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;crate&lt;/th&gt;
&lt;th&gt;lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;windows-sandbox-rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21,399&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linux-sandbox&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9,846&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sandboxing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9,220&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;execpolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,975&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bwrap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;43,591&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four platform paths: Apple Seatbelt, Linux Landlock, bubblewrap, and a Windows implementation that is by itself the largest of the four. The Seatbelt policies are not generated from some abstraction, they are checked-in &lt;code&gt;.sbpl&lt;/code&gt; files: a base policy, a network policy, a preferences policy, and a set of read-only platform defaults.&lt;/p&gt;

&lt;p&gt;Set against the agent core, with tests excluded on both sides so the two numbers are the same kind of number, that is 32,774 lines of sandboxing against 125,574. About a quarter of the core, spent entirely on making the operating system say no.&lt;/p&gt;

&lt;p&gt;I had that ratio wrong in a draft of this piece, and the way I got it wrong is the subject of the article. I compared the sandbox crates counted &lt;strong&gt;with&lt;/strong&gt; tests against a core counted &lt;strong&gt;without&lt;/strong&gt; them, and got "roughly a third." Both figures were real. Neither was wrong on its own. They just were not measuring the same thing, and the sentence built on them read as authoritative anyway.&lt;/p&gt;

&lt;p&gt;Which is exactly why Pi's position is defensible rather than lazy. That is a large, permanently expensive, four-platform commitment. A small team can reasonably decline it and tell users to bring their own container. Pi's README points at three concrete patterns for doing so: a micro-VM extension, plain Docker, and a policy-controlled sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt that is not a permission
&lt;/h2&gt;

&lt;p&gt;Pi does have a trust prompt, and it is the reason this piece exists. &lt;code&gt;packages/coding-agent/src/core/project-trust.ts&lt;/code&gt; asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Trust project folder?&lt;br&gt;
This allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you have used any modern editor you have seen this dialog, and you know what it feels like it means. It feels like the thing that decides whether the agent is allowed to touch your machine.&lt;/p&gt;

&lt;p&gt;It is not. Read the sentence again. It gates loading project configuration, installing project packages, and executing project extensions. It says nothing about the agent's own tools, because those were never gated. &lt;code&gt;bash&lt;/code&gt; is available either way. Answering "no" to that prompt does not put the agent in a box, because there is no box. The README already told us so.&lt;/p&gt;

&lt;p&gt;So a user meets a trust dialog, answers it carefully, and walks away with a belief about their security posture that the dialog did not create and does not support. Pi documented the truth in two places. The dialog is still the thing people will remember, because a dialog interrupts you and a README does not.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://harrisonsec.com/blog/evidence-that-isnt-evidence/" rel="noopener noreferrer"&gt;evidence that is not evidence&lt;/a&gt; in its purest form. A true signal, about a real decision, borrowed for a claim it was never making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than it was last year
&lt;/h2&gt;

&lt;p&gt;Because the input got hostile.&lt;/p&gt;

&lt;p&gt;I have been making the argument that a downloaded artifact is untrusted input, most recently about &lt;a href="https://harrisonsec.com/blog/gguf-loader-untrusted-model-files/" rel="noopener noreferrer"&gt;GGUF model files&lt;/a&gt;, where sixty-five bytes crash a loader. That is a parser problem with a parser fix.&lt;/p&gt;

&lt;p&gt;Agent context is not a parser problem. Pi's SECURITY.md is admirably blunt about it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pi relies on users installing trustworthy extensions and loading trustworthy skills and only to use pi within trusted repositories. This is because files like &lt;code&gt;AGENTS.md&lt;/code&gt; or instructions in comments can be used to prompt inject the coding agent trivially and this cannot be protected against.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is correct and it applies to every harness in the field, not just Pi. There is no parse step where you can validate an instruction, because instructions are the payload. A comment in a vendored dependency is in the context window with the same standing as your own words.&lt;/p&gt;

&lt;p&gt;So the trust boundary sits somewhere unfamiliar. The agent will at some point act on text written by someone else. That is not a hypothetical, it is the design. The only question left is what the process is capable of when it does.&lt;/p&gt;

&lt;p&gt;Which is precisely the question a sandbox answers and an approval prompt does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;Decide which of the four quadrants you are in, and say it out loud. Most people have never made this an explicit choice, and defaults chose for them.&lt;/p&gt;

&lt;p&gt;If you run an agent unattended, on a repository you did not write, with network access and your real credentials in the environment, then approvals are not protecting you and never were. Containerize. Pi's own docs tell you how, and Codex will do it for you.&lt;/p&gt;

&lt;p&gt;If you run Codex, set both enums deliberately. &lt;code&gt;ReadOnly&lt;/code&gt; plus &lt;code&gt;OnRequest&lt;/code&gt; is a genuinely good default and it will annoy you into weakening it. When you weaken it, weaken the axis you meant to.&lt;/p&gt;

&lt;p&gt;If you run Pi, run it the way its authors say to run it: inside something. The four tools are read, bash, edit and write, and &lt;code&gt;bash&lt;/code&gt; is not a smaller capability because there are only four of them.&lt;/p&gt;

&lt;p&gt;And whatever you run, stop counting approval prompts as a control. A prompt records that you agreed. Only the operating system records what was possible.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Line counts measured 2026-09-01 against &lt;code&gt;github.com/openai/codex&lt;/code&gt; and &lt;code&gt;github.com/earendil-works/pi&lt;/code&gt; at that day's HEAD, including test files. Enum definitions quoted from &lt;code&gt;codex-rs/protocol/src/config_types.rs&lt;/code&gt; and &lt;code&gt;codex-rs/protocol/src/protocol.rs&lt;/code&gt;. Pi quotations are verbatim from its README.md and SECURITY.md.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Three Agent Harnesses, One Loop</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Tue, 01 Sep 2026 08:01:21 +0000</pubDate>
      <link>https://dev.to/harrisonsec/three-agent-harnesses-one-loop-4on5</link>
      <guid>https://dev.to/harrisonsec/three-agent-harnesses-one-loop-4on5</guid>
      <description>&lt;p&gt;Claude Code's agent loop is 1,729 lines of TypeScript. Codex's is 983 lines of Rust. Pi's is 794 lines of TypeScript.&lt;/p&gt;

&lt;p&gt;Three teams. Three languages. No shared code, no shared lineage, and in Pi's case an explicit design goal of being the small one. The loop lands in the same place anyway.&lt;/p&gt;

&lt;p&gt;That convergence is the least interesting thing about these three systems, and almost every comparison I have read stops there. The interesting part is what does not converge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reading is legal now
&lt;/h2&gt;

&lt;p&gt;When I took apart Claude Code's &lt;code&gt;query.ts&lt;/code&gt; in &lt;a href="https://harrisonsec.com/blog/claude-code-deep-dive-query-loop/" rel="noopener noreferrer"&gt;Part 2 of the deep dive&lt;/a&gt;, the only reason it was possible was a leak. That was a real limitation. Line numbers from a leaked bundle are a snapshot of a build nobody can check, and I could not tell you whether any of it survived the next release.&lt;/p&gt;

&lt;p&gt;That constraint is gone.&lt;/p&gt;

&lt;p&gt;OpenAI published the Codex harness under Apache-2.0 in August 2026, including &lt;code&gt;codex exec&lt;/code&gt;, the SDK, and the &lt;code&gt;app-server&lt;/code&gt; that hosts the agent core. Pi is MIT. Both are on GitHub with permanent history. Every number in this piece comes from a clone I made on 2026-09-01, and you can check all of them.&lt;/p&gt;

&lt;p&gt;It moved the other way for Claude Code, which is worth saying because it cuts against my own series. I checked the install on this machine while writing: version 2.1.252 ships as a 197 MB compiled Mach-O binary. The JavaScript is in there, minified into single lines of 300,000 characters, and its internal codename appears three thousand times in the strings. It is not source. The numbers I published from the leaked bundle cannot be re-derived from what Anthropic actually ships today, by me or anyone else.&lt;/p&gt;

&lt;p&gt;So in the same twelve months, two harnesses became readable and one became less so. Every Claude Code figure below is a historical measurement of a build that is no longer inspectable, and I am going to keep flagging that rather than let it pass as current.&lt;/p&gt;

&lt;p&gt;With that stated, the useful question stopped being "what does a production agent loop look like inside." We can mostly see. The question now is why three teams solving the same problem produced three architectures that agree about the middle and disagree about the edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The middle: everyone wrote the same loop
&lt;/h2&gt;

&lt;p&gt;Strip each implementation to its control flow and you get the same five steps.&lt;/p&gt;

&lt;p&gt;Assemble context. Call the model. If the response contains tool calls, execute them. Append the results to the conversation. Decide whether to loop again or stop.&lt;/p&gt;

&lt;p&gt;That is it. That is the whole agentic pattern, and it fits in a paragraph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaga6ppfe204voe0uw1a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaga6ppfe204voe0uw1a.webp" alt="The agent loop, five steps" width="799" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what it costs each team to actually ship it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;lines&lt;/th&gt;
&lt;th&gt;language&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;query.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,729&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;&lt;code&gt;codex-rs/core/src/codex_thread.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;983&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages/agent/src/agent-loop.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;794&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude Code's &lt;code&gt;while(true)&lt;/code&gt; runs from line 307 to line 1728, so the loop body alone is 1,421 lines. Codex and Pi spread more of the same work across neighbouring modules, so file-to-file is the honest comparison, and file-to-file the spread is 794 to 1,729. Slightly more than a factor of two, across three independent teams.&lt;/p&gt;

&lt;p&gt;I want to flag something here, because I got it wrong in print.&lt;/p&gt;

&lt;p&gt;That Part 2 article carried a comparison table with a row claiming most open-source agents implement the loop in "~50-200 lines." I believed it when I wrote it. It is false, and Pi is the proof: the most deliberately minimal harness in the field spends 794 lines on its loop. The 50-line version exists in tutorials. It does not exist in anything people run all day. The gap I described as "Claude Code versus open source" was really "production versus demo," and open source has since closed it.&lt;/p&gt;

&lt;p&gt;Here is the shape in Pi's own source, trimmed to the control flow. The full function is&lt;br&gt;
&lt;code&gt;runAgentLoopContinue&lt;/code&gt; in &lt;code&gt;packages/agent/src/agent-loop.ts&lt;/code&gt;, starting at line 121:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hasMoreToolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasMoreToolCalls&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pendingMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// inject any steering messages the user typed mid-turn&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pendingMessages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;currentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;pendingMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;streamAssistantResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;streamFunction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aborted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;toolCall&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;hasMoreToolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;executedToolBatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;length&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;failToolCallsFromTruncatedMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeToolCalls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;hasMoreToolCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;executedToolBatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;executedToolBatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;currentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assemble, call, execute, append, repeat. The five steps are all there and they take about&lt;br&gt;
twenty lines.&lt;/p&gt;

&lt;p&gt;Now look at the ternary in the middle, because it is the entire subject of this section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the extra lines buy
&lt;/h2&gt;

&lt;p&gt;That branch on &lt;code&gt;stopReason === "length"&lt;/code&gt; is the loop noticing that the model's output was&lt;br&gt;
cut off by the token limit. When that happens, every tool call in the message may carry&lt;br&gt;
truncated arguments, so Pi fails all of them rather than executing calls whose parameters&lt;br&gt;
might be half a JSON object. The source comment is blunter than I would have been: execute&lt;br&gt;
them and you are running "potentially borked calls."&lt;/p&gt;

&lt;p&gt;That is one branch. It is not intelligence, no user will ever see it, and leaving it out&lt;br&gt;
would produce a loop that works in every demo and corrupts a file once a week.&lt;/p&gt;

&lt;p&gt;The five-step summary omits every hard part, and each team's loop is mostly the omissions.&lt;/p&gt;

&lt;p&gt;A response can stream, so tool calls arrive before the message is finished and you have to decide whether to start executing. Tool calls can run in parallel or must not, depending on what they touch. The user can interrupt mid-turn, which means an abort has to unwind cleanly without corrupting the transcript. Context fills up, so compaction has to fire without losing the thread. Providers fail in ways that are worth retrying and ways that are not. Approvals can suspend the loop indefinitely while a human decides.&lt;/p&gt;

&lt;p&gt;None of that is intelligence. All of it is the difference between a demo and a tool, which is the argument I made in &lt;a href="https://harrisonsec.com/blog/ai-agent-90-percent-problem/" rel="noopener noreferrer"&gt;The 90% Problem&lt;/a&gt; before I could check it against three codebases. Now I can. The loop is 5 steps and 800 to 1,700 lines, and the ratio between those two numbers is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edges: three different boundaries
&lt;/h2&gt;

&lt;p&gt;Here is where they stop agreeing.&lt;/p&gt;

&lt;p&gt;Every harness has to answer one question: what is outside the loop, and how does the loop talk to it. Each of these three gave a different answer, and the answer determines nearly everything else about the system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmldxhz3gtrquy8krrvbl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmldxhz3gtrquy8krrvbl.webp" alt="Where each harness draws its boundary" width="800" height="617"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude Code puts the boundary inside the process
&lt;/h3&gt;

&lt;p&gt;The loop, the tools, the permission checks, the compaction and the UI live together. Coordination is function calls and shared state. That buys tight control over things that are painful to coordinate across a wire, and the &lt;a href="https://harrisonsec.com/blog/claude-code-context-engineering-compression-pipeline/" rel="noopener noreferrer"&gt;five-level compression pipeline&lt;/a&gt; is the clearest example: it can make decisions using information the loop has not committed to anything yet.&lt;/p&gt;

&lt;p&gt;The cost is that everything must be in-process. Another program cannot drive the agent without going through the surface Anthropic chose to expose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codex puts the boundary on a protocol
&lt;/h3&gt;

&lt;p&gt;Codex pulled the agent core into &lt;code&gt;app-server&lt;/code&gt; and put a documented, bidirectional JSON-RPC 2.0 interface in front of it. The README is explicit about the three primitives:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Thread&lt;/strong&gt;: A conversation between a user and the Codex agent. Each thread contains multiple turns.&lt;br&gt;
&lt;strong&gt;Turn&lt;/strong&gt;: One turn of the conversation, typically starting with a user message and finishing with an agent message.&lt;br&gt;
&lt;strong&gt;Item&lt;/strong&gt;: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not an implementation detail written down after the fact. It is a contract. &lt;code&gt;thread/start&lt;/code&gt;, &lt;code&gt;thread/resume&lt;/code&gt;, &lt;code&gt;thread/fork&lt;/code&gt; to branch with copied history, &lt;code&gt;turn/start&lt;/code&gt;, &lt;code&gt;turn/interrupt&lt;/code&gt;, streaming &lt;code&gt;item/started&lt;/code&gt; and &lt;code&gt;item/completed&lt;/code&gt; notifications, and &lt;code&gt;turn/completed&lt;/code&gt; carrying final token usage.&lt;/p&gt;

&lt;p&gt;Transports are stdio by default, with a unix socket and an experimental websocket listener. There is even backpressure in the protocol: saturate request ingress and you get JSON-RPC error &lt;code&gt;-32001&lt;/code&gt;, &lt;code&gt;"Server overloaded; retry later."&lt;/code&gt; A harness that has an overload error code has stopped thinking of itself as a CLI.&lt;/p&gt;

&lt;p&gt;The consequence is that the VS Code extension, the terminal, and any third-party client are all peers. None of them is the real one. The agent is a service, and the loop is what runs inside it.&lt;/p&gt;

&lt;p&gt;The cost is versioning. Once thread and turn are wire types, changing them is a compatibility event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi puts the boundary at the extension host
&lt;/h3&gt;

&lt;p&gt;Pi ships four tools: read, bash, edit, write. Everything else is a TypeScript extension, loaded from the project, hot-reloadable, and able to register tools, persist state into the session, and render its own terminal components.&lt;/p&gt;

&lt;p&gt;That is a genuinely different bet. Codex says the interesting extensibility is other processes driving the agent. Pi says it is the agent growing new tools inside your repository, at runtime, in the same language as the harness. Pi deliberately ships no MCP support at all, which I will come back to in a later piece, because the reasoning is better than the summaries suggest.&lt;/p&gt;

&lt;p&gt;The cost is that the boundary is a language boundary. Extensions are TypeScript because the host is TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimalism that is not
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me most, and it is the part the coverage keeps getting wrong.&lt;/p&gt;

&lt;p&gt;Pi's reputation is radical minimalism. Four tools. A system prompt small enough that people quote its token count. That reputation is earned. I measured the default prompt at 550 tokens, which is genuinely tiny, and I will take that number apart properly in a later piece because the interesting half is what it leaves out.&lt;/p&gt;

&lt;p&gt;Now count the codebase.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;source lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pi, &lt;code&gt;packages/*/src&lt;/code&gt;, tests excluded&lt;/td&gt;
&lt;td&gt;121,240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex, &lt;code&gt;codex-rs/core/src&lt;/code&gt;, tests excluded&lt;/td&gt;
&lt;td&gt;125,574&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The minimal agent and the OpenAI agent core are within four percent of each other.&lt;/p&gt;

&lt;p&gt;Inside Pi that is 60,960 lines in the coding agent, 23,668 in the provider layer, 17,000 in the terminal UI, 12,640 in the agent package. This is not bloat and it is not a gotcha. It is what shipping takes.&lt;/p&gt;

&lt;p&gt;Both things are true at once, and holding them together is the actual lesson. Pi minimizes &lt;strong&gt;what the model has to reason about&lt;/strong&gt;. It does not minimize &lt;strong&gt;what the team has to maintain&lt;/strong&gt;, and it never claimed to. Those are two different surfaces, and almost every writeup I have seen collapses them into one sentence about how small Pi is.&lt;/p&gt;

&lt;p&gt;The same confusion runs the other way with Claude Code. A 510,000-line bundle sounds like the opposite of minimal, until you notice the model still only sees a loop and a tool list.&lt;/p&gt;

&lt;p&gt;Model-facing surface and engineering surface are independent axes. You can be small on one and large on the other, and every one of these three is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boundary is the decision
&lt;/h2&gt;

&lt;p&gt;Once you see the boundary as the design, the rest of each system reads as consequence rather than taste.&lt;/p&gt;

&lt;p&gt;Codex has 43,591 lines of sandboxing crates and four platform backends because a service that anyone can drive cannot assume the caller is a trusted human at a terminal. Pi has none of that, and says so in its README, because it assumes exactly that. Neither is careless. They are answers to different questions, and I will take that specific difference apart next, because it is the one with security consequences that people are getting wrong right now.&lt;/p&gt;

&lt;p&gt;Codex's &lt;code&gt;thread/fork&lt;/code&gt; and Pi's parent-linked session tree exist for the same reason and were reached independently: once a session is a real object rather than a transcript, branching is cheap and re-running from a known point stops costing you the whole context.&lt;/p&gt;

&lt;p&gt;This is the same move as &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;the technique boundary&lt;/a&gt;. Deciding where determinism ends and judgement begins is the design. Deciding where your process ends and the outside begins is the same kind of decision, one level down.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;p&gt;Not by benchmark. Benchmarks of harnesses measure the harness plus the model plus the configuration, which is a trap I will spend a whole piece on shortly.&lt;/p&gt;

&lt;p&gt;Choose by boundary.&lt;/p&gt;

&lt;p&gt;If other processes need to drive the agent, take Codex. The protocol exists, it is documented, it generates its own TypeScript and JSON Schema, and rebuilding that on top of a library is months of work that ends worse.&lt;/p&gt;

&lt;p&gt;If the agent needs to grow tools inside your codebase and you can live in TypeScript, take Pi. The extension host is the shortest path from "the agent should be able to do X here" to the agent doing X, and hot reload means the agent can write and test its own tools.&lt;/p&gt;

&lt;p&gt;If neither is true, you may not need a harness. A loop you understand, with the four tools everyone converged on anyway, is 800 lines. That is not a small number, but it is a knowable one, and now you can read three implementations before you write yours.&lt;/p&gt;

&lt;p&gt;The loop was never the moat. It was the part that was always going to converge.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pi and Codex measurements taken 2026-09-01 against &lt;code&gt;github.com/earendil-works/pi&lt;/code&gt; and &lt;code&gt;github.com/openai/codex&lt;/code&gt; at that day's HEAD, and they are reproducible. Claude Code figures come from the leaked bundle analysed in &lt;a href="https://harrisonsec.com/blog/claude-code-deep-dive-query-loop/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;. I tried to re-verify them against the shipping artifact and could not: Claude Code 2.1.252 is a compiled binary with minified JavaScript embedded, so line counts from the leaked source have no counterpart in it. Treat those three numbers as a snapshot of an older build, not a current measurement.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Shamir Secret Sharing, Explained With Pictures</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Tue, 01 Sep 2026 01:59:56 +0000</pubDate>
      <link>https://dev.to/harrisonsec/shamir-secret-sharing-explained-with-pictures-4pi8</link>
      <guid>https://dev.to/harrisonsec/shamir-secret-sharing-explained-with-pictures-4pi8</guid>
      <description>&lt;p&gt;There is a claim in &lt;a href="https://stegosafe.com/blog/five-photos-any-three-recover-your-seed-phrase/?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;the seed phrase write-up&lt;/a&gt; that deserves more than an assertion:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two shares don't get you "most of the way there". They get you nowhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds like marketing. It is not — it is a property you can draw on paper in about a minute, and once you have seen it drawn you will not need to trust anyone about it again. So here is the drawing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with two points and a line
&lt;/h2&gt;

&lt;p&gt;Pick any two points on a page. Exactly one straight line passes through both of them. Not "roughly one" — one. Take away either point and infinitely many lines fit the survivor.&lt;/p&gt;

&lt;p&gt;That asymmetry is the entire scheme. Adi Shamir published it in 1979, and the trick is to hide a secret inside a curve rather than inside a container:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put the secret at one specific spot on the curve — where it crosses the axis, at x = 0.&lt;/li&gt;
&lt;li&gt;Draw the rest of the curve however you like, at random.&lt;/li&gt;
&lt;li&gt;Hand out &lt;em&gt;other&lt;/em&gt; points on that curve. Those are the shares.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nobody holds the secret. Everybody holds a point that the secret's curve happens to pass through.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi5xp23fj69mzem8rots.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi5xp23fj69mzem8rots.png" alt="One share leaves every possible secret on the table. Two pin down the line, and with it the intercept." width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the left panel. One point, and five different lines drawn through it, each hitting the axis somewhere different. There is nothing special about those five; you could draw a line through that point to &lt;em&gt;any&lt;/em&gt; intercept you like. So a person holding one share cannot narrow the secret down at all. Not to a range, not to a likely region. Every value is still exactly as plausible as it was before they had the share.&lt;/p&gt;

&lt;p&gt;Now the right panel. A second point fixes the slope, one line fits both, and following it back to the axis gives the secret. Two shares, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three of five is the same trick, one degree up
&lt;/h2&gt;

&lt;p&gt;A line is fixed by two points. A parabola takes three. Curve upward through degree and you get any threshold you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2-of-N&lt;/strong&gt;: put the secret on a line, hand out points on it. Any two shares reconstruct the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3-of-N&lt;/strong&gt;: put the secret on a parabola. Any three reconstruct it; two leave infinitely many parabolas fitting, one for each candidate secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k-of-N&lt;/strong&gt;: a curve of degree k−1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The N is free. Five shares from a parabola, or fifty, and the threshold stays at three either way, because it is a property of the curve's degree rather than of how many points you gave away. That is why 3-of-5 and 3-of-50 are equally secure against two attackers, and why generosity with shares costs you nothing in threshold terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest footnote about the picture
&lt;/h2&gt;

&lt;p&gt;The drawing uses ordinary school-graph arithmetic, and real implementations do not. Points on a real graph leak information: a share sitting at an extreme height narrows what the intercept could plausibly be, and floating-point rounding would corrupt the reconstruction anyway.&lt;/p&gt;

&lt;p&gt;So implementations work in a &lt;strong&gt;finite field&lt;/strong&gt; — arithmetic on a fixed set of values that wraps around instead of running off to infinity, usually GF(256), which is to say bytes. Addition, multiplication and division all still work, the "one curve through k points" theorem still holds exactly, and there is no rounding because there are no fractions. What changes is that "extreme value" stops meaning anything: every share is a byte, uniformly distributed, and the geometry that made the picture intuitive stops leaking the hints that made it intuitive.&lt;/p&gt;

&lt;p&gt;The picture is true about the structure. The field is what makes it true about the security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where SLIP39 fits, and what it is not competing with
&lt;/h2&gt;

&lt;p&gt;If you have looked into split backups for a wallet you will have met SLIP39, and probably seen it framed as "SLIP39 vs BIP39". That framing is misleading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BIP39 is not a splitting scheme.&lt;/strong&gt; It turns one seed into one phrase of English words. There is nothing to compare — it solves encoding, not distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SLIP39 applies Shamir's scheme&lt;/strong&gt; and then encodes each resulting share as its own word list, so the thing you write down looks familiar. It adds grouping on top, so you can express rules like "two of the three family shares, plus the lawyer's".&lt;/p&gt;

&lt;p&gt;So the real comparison is &lt;strong&gt;one backup versus a threshold of several&lt;/strong&gt;, and it is a question about failure modes rather than about formats. One phrase in one place fails when that place fails, in either direction: lost, or found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a threshold beats a safe
&lt;/h2&gt;

&lt;p&gt;A safe is a single point of failure wearing armour. It is very good at the thing it does — resisting force — and it concentrates every other risk into one location. Whoever opens it has everything. If it burns, everything is gone.&lt;/p&gt;

&lt;p&gt;A 3-of-5 split inverts that. There is no single object to steal, and no single place to lose. An attacker needs to find and take three separate things, and you can lose two of them to fire, flood, a house move or a forgotten hiding place and still be fine.&lt;/p&gt;

&lt;p&gt;What it does &lt;em&gt;not&lt;/em&gt; do is survive time. Which brings us to the part that gets skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What splitting does not fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It does not make anything durable.&lt;/strong&gt; Five copies that all degrade are five copies of nothing. Splitting solves the &lt;em&gt;single point of failure&lt;/em&gt; problem, not the &lt;em&gt;the medium died&lt;/em&gt; problem. A steel plate is still better than any digital medium at surviving a house fire, and the two are complements rather than alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery reassembles the whole secret in one place.&lt;/strong&gt; For the moment reconstruction happens, on whatever device does it, the complete secret exists — and that device is now the single point of failure the scheme was built to eliminate. The protection is real in storage and absent in use. Anyone telling you a threshold scheme means the secret "never exists in one place" is describing something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not choose your locations for you.&lt;/strong&gt; Three shares in three drawers of the same desk is a 3-of-5 split with the security of one drawer. The mathematics is only as good as the independence of the places.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real run, and the one thing the diagram leaves out
&lt;/h2&gt;

&lt;p&gt;Everything above is the scheme. Here is our own CLI actually doing it, because a&lt;br&gt;
drawing of a threshold and a threshold are different claims.&lt;/p&gt;

&lt;p&gt;Five ordinary photos, one sentence hidden across them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stegosafe_cli.py embed -i in -s "the mitochondria is the powerhouse of the cell" -o out
Key length: 32 bytes, Ciphertext length: 74 bytes
Key split into 5 shares
Share 1: x=1, y length=32
Total data length to embed: 107 bytes
Saved: out/stego_1.png
...
Encryption and embedding complete. Created 5 stego images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read the second line again, because it is the part the diagram does not show.&lt;/strong&gt;&lt;br&gt;
What gets split five ways is not the secret. It is the 32-byte AES key. Every&lt;br&gt;
image carries the &lt;em&gt;whole&lt;/em&gt; ciphertext plus one share of the key — 74 + 32 bytes,&lt;br&gt;
107 with framing. You need three images because that is how many it takes to&lt;br&gt;
rebuild the key, not because the message is in pieces.&lt;/p&gt;

&lt;p&gt;That is a better design than splitting the message would be, and it is worth&lt;br&gt;
saying why: a share of a key is a fixed 32 bytes whatever the secret's length, so&lt;br&gt;
the scheme costs the same whether you hide a twelve-word phrase or a paragraph.&lt;br&gt;
It also means every image is independently useless in the same way — ciphertext&lt;br&gt;
with no key is ciphertext.&lt;/p&gt;

&lt;p&gt;Now delete two of the five and recover from what is left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rm out/stego_2.png out/stego_4.png
$ stegosafe_cli.py recover -i out
Collected 3 shares, starting recovery
Recovered key length: 32 bytes

Recovered secret:
the mitochondria is the powerhouse of the cell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with only two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stegosafe_cli.py recover -i two
Error: At least 3 stego images are required to recover the secret
Found 2 stego images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It refuses. It does not return a partial phrase, and it does not return a wrong&lt;br&gt;
one — which matters more than it sounds, because a recovery tool that guesses is&lt;br&gt;
worse than one that stops.&lt;/p&gt;

&lt;p&gt;The cost to the photographs, measured across the five: &lt;strong&gt;between 0.0067% and&lt;br&gt;
0.0117% of pixels changed, and no colour channel moved by more than 1 out of&lt;br&gt;
255.&lt;/strong&gt; The percentage is higher on the smallest image for the obvious reason —&lt;br&gt;
the payload is a fixed number of bytes, so it occupies a larger share of a&lt;br&gt;
smaller picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it rather than believing it
&lt;/h2&gt;

&lt;p&gt;The claim at the top of this page is checkable in ten minutes and does not require trusting us. Split a sentence you do not care about into five shares, delete two, recover from three. Then take a single share and try to get anything out of it at all.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/harrison001/stegosafe-cli" rel="noopener noreferrer"&gt;command-line tool is open source&lt;/a&gt; if you would rather read the implementation than the explanation. What StegoSafe adds on top of the scheme described here is where the shares &lt;em&gt;live&lt;/em&gt; — each one inside an ordinary photograph rather than in a file that announces itself as a share — which is &lt;a href="https://stegosafe.com/blog/five-photos-any-three-recover-your-seed-phrase/?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;the subject of the seed phrase write-up&lt;/a&gt;, and of &lt;a href="https://stegosafe.com/blog/what-happens-when-you-hide-a-secret-in-a-photo/?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;what actually happens when you hide a secret in a photo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shamir</category>
      <category>cryptography</category>
      <category>explainer</category>
      <category>slip39</category>
    </item>
    <item>
      <title>Your Model File Is Untrusted Input</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Wed, 26 Aug 2026 15:51:53 +0000</pubDate>
      <link>https://dev.to/harrisonsec/your-model-file-is-untrusted-input-1eap</link>
      <guid>https://dev.to/harrisonsec/your-model-file-is-untrusted-input-1eap</guid>
      <description>&lt;p&gt;Sixty-five bytes is not a model. It is not even a header with something plausible after it. It is a GGUF magic number, a few counts, and a tensor whose second dimension is zero.&lt;/p&gt;

&lt;p&gt;Load it and llama.cpp dies.&lt;/p&gt;

&lt;p&gt;Not with a memory error, and not because anything was corrupted. It dies dividing by a number that the file format is supposed to allow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loader is the trust boundary
&lt;/h2&gt;

&lt;p&gt;Nobody thinks of a model file as input. You download a &lt;code&gt;.gguf&lt;/code&gt; from a hub the way you download a JPEG, and then a C++ binary parses it into heap allocations, tensor shapes and metadata keys. Everything downstream reaches the same parser: &lt;code&gt;llama-server&lt;/code&gt;, the bindings, the desktop apps that wrap them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gguf_init_from_buffer&lt;/code&gt; is where a byte array becomes a model. That makes it the boundary, and boundaries are worth fuzzing.&lt;/p&gt;

&lt;p&gt;The harness is unremarkable: libFuzzer and AddressSanitizer pointed at that one function. Binary formats with length fields are where parsers go wrong, and a model file is a binary format that is nothing but length fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sixty-five bytes, field by field
&lt;/h2&gt;

&lt;p&gt;The file that kills it, laid out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;offset&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;magic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GGUF&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;version&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;tensor count&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;metadata kv count&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;name length&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;tensor name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;n_dims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ne[0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;45&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;ne[1]&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;type&lt;/td&gt;
&lt;td&gt;0 (&lt;code&gt;F32&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;offset&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A version the loader supports. One tensor, no metadata, a one-character name, two dimensions, the first of them 1. Fifty-seven of those sixty-five bytes are the least remarkable file you could construct.&lt;/p&gt;

&lt;p&gt;The other eight are at offset 45, and what makes them fatal is that they are &lt;em&gt;zero&lt;/em&gt;, not that they are wrong. Nothing here is malformed. There is no length lying about a buffer, no count that overruns an allocation, no string missing its terminator, none of the things a parser is written to be suspicious of. Every field is in range, including that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first crash, and the check that was already there
&lt;/h2&gt;

&lt;p&gt;Tensor dimensions are validated. The loader checks that each one is non-negative, which reads like exactly the right thing to do, and it has been there a long time.&lt;/p&gt;

&lt;p&gt;Then the next check tests whether the element count can be represented at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;INT64_MAX&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That divides by &lt;code&gt;ne[1]&lt;/code&gt;. A dimension of zero passes the first check, because zero is not negative, and arrives at the second, where &lt;code&gt;INT64_MAX / 0&lt;/code&gt; raises SIGFPE and the process is gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="nl"&gt;AddressSanitizer:&lt;/span&gt; &lt;span class="n"&gt;FPE&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="n"&gt;gguf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cpp&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gguf_init_from_reader&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What makes this one worth writing down is not the arithmetic. It is that a validation check sat directly above the crash, and it passed review, and it is correct as far as it goes. &lt;code&gt;ne[j] &amp;gt;= 0&lt;/code&gt; is a true statement about what a dimension may be. It just is not the statement the line below it needed. A check that half-covers a value is worse than no check, because the reader below it stops asking. That is the &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;wrong ruler&lt;/a&gt; again, a check that reads as complete and quietly is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero is legal, so rejecting it was never the fix
&lt;/h2&gt;

&lt;p&gt;The obvious patch is to demand a positive dimension. That patch is wrong.&lt;/p&gt;

&lt;p&gt;Zero-sized tensors are allowed on purpose. They exist in real files, where some diffusion models carry them as feature tags, marking a capability rather than holding data, and safetensors permits them too. A loader that began refusing them would reject models that are not malformed at all.&lt;/p&gt;

&lt;p&gt;So the fix is not about the input. It is about the arithmetic: skip the representability check when a dimension is zero, because an element count of zero is trivially representable. Nothing is rejected that was not already rejected. The division simply stops happening on a value that was always going to be there.&lt;/p&gt;

&lt;p&gt;This came up in review. The maintainer's recollection was that zero-sized tensors were tolerated but pointless; a collaborator corrected it with the diffusion case. The correction is the reason the patch stayed narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second crash: a type nobody checked
&lt;/h2&gt;

&lt;p&gt;The other one is smaller and more ordinary.&lt;/p&gt;

&lt;p&gt;GGUF metadata is typed. Each key declares what it holds, and the reader is supposed to honour that declaration. &lt;code&gt;general.alignment&lt;/code&gt; is read as a &lt;code&gt;uint32&lt;/code&gt;, without asking what the file said it was.&lt;/p&gt;

&lt;p&gt;A file that declares &lt;code&gt;general.alignment&lt;/code&gt; with any other type reaches a template whose whole job is to notice that mismatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;GGML_ASSERT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type_to_gguf_type&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An assertion is not a safety mechanism against hostile input. It is a statement that something impossible has happened, and its response is to abort. Here it was reachable from a 62-byte file, which means the impossible thing was merely undeclared.&lt;/p&gt;

&lt;p&gt;The fix is a type check before the read, and a clean failure instead of an abort.&lt;/p&gt;

&lt;h2&gt;
  
  
  What review changed
&lt;/h2&gt;

&lt;p&gt;I opened the pull request with the fix and the two reproducers. Three things came back, and each one made it better than what I sent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests.&lt;/strong&gt; The first comment asked for regression tests, which was the right call and the part I had skipped. Two handcrafted files went into &lt;code&gt;tests/test-gguf.cpp&lt;/code&gt;: a metadata key with the wrong type, and a tensor with a zero-size dimension. Both crash the old build and load cleanly against the fix. The suite went 164/164.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix itself.&lt;/strong&gt; My patch tested the dimensions one at a time. The review asked for &lt;code&gt;ggml_nelements(info.t) &amp;gt; 0&lt;/code&gt; instead. Identical behaviour, but it says what it means: skip the overflow check when there are no elements to overflow. Checking each dimension is how you think while you are staring at a division. Checking the element count is what the condition was always about, and it is one statement instead of several, which matters for a check whose whole failure mode was that a reader trusted it without enumerating the cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The error message.&lt;/strong&gt; Rejecting a wrongly-typed key is not much help if it won't say what it wanted. It now reports both sides: &lt;code&gt;key 'general.alignment' must be of type u32 but is i32&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That exchange is worth more than the patch. A fix without a regression test lasts until the next refactor, and a fix phrased as three separate conditions is an invitation for someone to eventually need a fourth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Someone else found it the same week
&lt;/h2&gt;

&lt;p&gt;Nine days after the pull request went up, a comment appeared on it from someone running an unrelated campaign against &lt;code&gt;gguf_init_from_buffer&lt;/code&gt;, libFuzzer with ASan and UBSan at around 400,000 iterations, who had landed on the same SIGFPE by the same route.&lt;/p&gt;

&lt;p&gt;That part is only mildly interesting. Two fuzzers aimed at the same function find the same shallow bug; that is what shallow means.&lt;/p&gt;

&lt;p&gt;What was worth reading is what they did next. They went through the surrounding overflow check by hand to see whether the patch was complete:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the &lt;code&gt;ne[0]&lt;/code&gt;/&lt;code&gt;ne[2]&lt;/code&gt;/&lt;code&gt;ne[3]&lt;/code&gt; multiplications can't independently overflow due to the &lt;code&gt;||&lt;/code&gt; short-circuit, so the zero-check in this PR looks like it fully covers the defect rather than being a partial fix&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the right question, and it is the same question this bug was made of. &lt;code&gt;ne[j] &amp;gt;= 0&lt;/code&gt; was a partial guarantee that read as a complete one, and it went unexamined precisely because it was there. Somebody checking whether its replacement had the same shape is the review this class of defect needs and almost never gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;Two things, neither of them about GGUF.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A downloaded model deserves the suspicion you give a downloaded binary.&lt;/strong&gt; It is parsed by native code before anything else happens to it. These two bugs stop at denial of service, which is survivable when you are crashing your own process and much less so when a service loads models on someone else's behalf. The category is the point, not the severity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation that half-covers a value is a liability.&lt;/strong&gt; &lt;code&gt;ne[j] &amp;gt;= 0&lt;/code&gt; is not wrong. It is simply not the guarantee the line below it depended on, and its presence is what made the line below it look safe. When you find a bounds check, the useful question is not whether it is correct but what the code underneath it now assumes. That is the same reflex as reading &lt;a href="https://harrisonsec.com/blog/evidence-that-isnt-evidence/" rel="noopener noreferrer"&gt;a signal that is present but not diagnostic&lt;/a&gt;: the check ran, and running is not the claim you needed it to make.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The fix is in &lt;a href="https://github.com/ggml-org/llama.cpp/pull/25596" rel="noopener noreferrer"&gt;llama.cpp#25596&lt;/a&gt;, merged August 2026, with both reproducers in the description. If you run models locally, you already have the patched loader.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a Parser Field Note. It shares its spine with &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A Wrong Ruler Is Worse Than No Ruler&lt;/a&gt; on a check that reads as complete and is not, and &lt;a href="https://harrisonsec.com/blog/evidence-that-isnt-evidence/" rel="noopener noreferrer"&gt;The Log Printed Exactly What I Wanted&lt;/a&gt; on a true signal that supports a smaller claim than you lean on it for.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cpp</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Merged Is Not Shipped: Why a Year-Old Fix Never Reached You</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Sat, 22 Aug 2026 18:52:49 +0000</pubDate>
      <link>https://dev.to/harrisonsec/merged-is-not-shipped-why-a-year-old-fix-never-reached-you-1nda</link>
      <guid>https://dev.to/harrisonsec/merged-is-not-shipped-why-a-year-old-fix-never-reached-you-1nda</guid>
      <description>&lt;p&gt;There is a version of a bug that is more frustrating than an unfixed one. It is a bug that was fixed, correctly, and then sat behind glass for a year while the people it was hurting kept reporting it, because the fix and the users were in two different repositories and nothing moved the one pointer between them.&lt;/p&gt;

&lt;p&gt;I ran into exactly this. The &lt;a href="https://harrisonsec.com/blog/nat64-vpn-raw-socket-address-substitution/" rel="noopener noreferrer"&gt;connectivity failure I wrote up separately&lt;/a&gt;, where an IPv6-only cellular network quietly rewrites a VPN address and one code path dies, has a clean upstream fix. It was merged in July 2025. It is small, it is correct, and its commit message describes the exact situation I spent a night diagnosing. And more than a year later, the users hitting the bug still could not get it, including if they built the app from source. Not because the fix was hard, or hidden, or controversial. Because one git submodule pointer was never advanced.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Merging changes the source of truth. It does not move the pointers between that source and a user's install. A fix is shipped only when every pinned reference on the path from the commit to the running binary has been advanced, and in a dependency graph that last move is owned by no one.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the delivery half of a two-part field note. The first part was about recognizing a failure that logs no error. This part is about a fix that shows every sign of being done and reaches nobody, which is the same illusion one layer up. It is also, underneath, the &lt;a href="https://harrisonsec.com/blog/tool-calls-need-completion-ownership/" rel="noopener noreferrer"&gt;completion-ownership problem&lt;/a&gt; again: &lt;em&gt;merged&lt;/em&gt; is the software-supply-chain version of a &lt;code&gt;202 Accepted&lt;/code&gt;. It says the request was received, not that the effect happened. Somebody has to own the claim that the fix actually reached users, and in an open dependency graph, nobody does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the gap
&lt;/h2&gt;

&lt;p&gt;The project here is structured the way a great many projects are. There is a core library that does the real work, and there are several client applications that each embed that library as a git submodule pinned to a specific commit. The pin is deliberate and correct engineering. It makes every client's build reproducible and keeps an upstream change from silently altering a released app. It is also, for the same reason, a freeze: the client is stuck at whatever point in the library's history the pointer names, no matter what has been fixed since.&lt;/p&gt;

&lt;p&gt;The fix landed in the library. Then look at where the three clients had their submodule pinned:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;client&lt;/th&gt;
&lt;th&gt;pinned library commit&lt;/th&gt;
&lt;th&gt;has the fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;desktop (Qt)&lt;/td&gt;
&lt;td&gt;mid-2026&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;early 2024&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;early 2024&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The iOS client's pointer was two dozen commits behind the fix. The App Store build was older still. So an iOS user could not get the fix by updating the app, and could not get it by building from source either, because building from source faithfully builds the pinned commit, which does not contain it.&lt;/p&gt;

&lt;p&gt;Then the detail that makes the whole thing land. The fix's own commit message says it is a workaround for iOS. It was written for iOS. And the only one of the three clients carrying it was the desktop client, which does not run on iOS. The fix reached every platform except the one it was written to help.&lt;/p&gt;

&lt;p&gt;Meanwhile the issue tracker for the client had the bug open for a year and a half, collecting &lt;em&gt;me too&lt;/em&gt; comments and workaround folklore, with no one having connected it to a commit that had been sitting in a sibling repository the entire time. The answer existed. It was one &lt;code&gt;git log&lt;/code&gt; away. The distance between the fix and the people who needed it was not technical. It was organizational, and it was invisible from both ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap is invisible from both ends
&lt;/h2&gt;

&lt;p&gt;The delivery gap survives because everyone who could close it is looking at a view where it does not appear.&lt;/p&gt;

&lt;p&gt;The library maintainer merges the fix, the tests pass, the issue in their tracker closes, and by every signal available to them the work is done. Their instrument does not show downstream pins. Nothing tells them that a dependent client is twenty-four commits behind and that real users are stranded on the far side of a pointer only that client's maintainer can move.&lt;/p&gt;

&lt;p&gt;The client maintainer is not watching the library's commit log commit by commit. Bumping a submodule is a deliberate, manual, and slightly risky act, because moving the pointer can drag in unrelated changes and API drift along with the one fix you wanted. It is unglamorous, it is easy to defer, and nothing schedules it. There is no notification that says &lt;em&gt;a dependency you pin has fixed a bug your users are reporting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The user sees only the bug, and an issue tracker full of other people seeing the same bug, which reads as strong evidence that the problem is simply unsolved. The one place the answer actually lived, the commit history of a dependency, is not a place most users think to look, and the tracker gives them no reason to.&lt;/p&gt;

&lt;p&gt;So the fix sits in the seam. Merging felt like the finish line to the one person positioned to know the fix existed, and moving the pointer was nobody's defined job, and the people motivated to close the gap were the only ones who could not see where it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is every lockfile you have
&lt;/h2&gt;

&lt;p&gt;It would be comforting to file this under git submodules, which have a reputation for exactly this kind of pain, and move on. But the submodule is only the most visible instance of a mechanism that is everywhere in modern software, and that mechanism is the pin.&lt;/p&gt;

&lt;p&gt;Every reproducibility guarantee you rely on is a frozen pointer. &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;go.sum&lt;/code&gt;, &lt;code&gt;Cargo.lock&lt;/code&gt;, a pinned &lt;code&gt;requirements.txt&lt;/code&gt;, a Docker &lt;code&gt;FROM image:tag&lt;/code&gt;, a vendored module, a Helm chart's image digest. Each one exists to protect you from unwanted change, and each one, by protecting you from unwanted change, also holds you at a known-good point that can quietly become a known-stale point the moment an upstream fix lands above it. The property that makes your build trustworthy is the same property that makes it a place fixes go to wait. You do not get deterministic builds and automatic fix delivery from the same pin. You get one, and you buy the other separately, with deliberate effort, or you do not get it at all.&lt;/p&gt;

&lt;p&gt;Which means the question &lt;em&gt;is a fix I need already merged upstream&lt;/em&gt; is the wrong question, and answering it &lt;em&gt;yes&lt;/em&gt; is how you end up stranded with false comfort. The right question is &lt;em&gt;has that fix crossed every pin between its commit and the thing I actually run.&lt;/em&gt; Those are very different questions, and the gap between them is measured in the months a user spends believing a bug is unsolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;For the person chasing a bug, the discipline is small and it saves real time. When you find that a bug is fixed upstream, do not stop at merged. Compare the fix commit against the exact revision your project pins, not against the upstream tip, and read the number of commits you are behind. Read commit history rather than the issue tracker, because the tracker is where the duplicate confusion pools and the commit log is where the resolution actually sits. And if you can, cherry-pick the fix onto your pinned revision and test it, which is what turns a guess into a verified claim and, incidentally, gives a maintainer a concrete reason to bump the pointer. That last step is the same move as everywhere else on this site: &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;do not assert that it works, prove it in a loop closed against reality&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the person maintaining a project that pins dependencies, the fix is structural, because relying on a frustrated user to eventually investigate is not a delivery process. Give the pointer an owner. Make advancing pins a tracked, routine task rather than an act of archaeology, and let automation open the update pull requests so that a dependency's fixes become visible events in your own repository instead of silence. The goal is that the gap between merged and shipped becomes something a system watches, rather than something only a stranded user ever notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was scarce, again
&lt;/h2&gt;

&lt;p&gt;The fix was never the scarce thing. It existed, correct and complete, for over a year. What was missing was the single unglamorous act of moving a pointer, an act that no dashboard flagged, no notification raised, and no person had been assigned. Everyone involved was behaving reasonably inside their own view. The maintainer had merged. The user had reported. The pointer sat between them, load-bearing and unowned, and a working fix helped no one for a year because of it.&lt;/p&gt;

&lt;p&gt;Merged is not shipped. The commit is only the beginning of the fix's journey to a user, and most of that journey runs through pins that someone has to advance on purpose. Until the last of them moves, the fix is real, and it is doing nothing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is the delivery companion to &lt;a href="https://harrisonsec.com/blog/nat64-vpn-raw-socket-address-substitution/" rel="noopener noreferrer"&gt;Your VPN Is Fine. iOS Swapped the Address.&lt;/a&gt;, the diagnosis that turned up the year-old fix. It shares its spine with &lt;a href="https://harrisonsec.com/blog/tool-calls-need-completion-ownership/" rel="noopener noreferrer"&gt;Tool Calls Need Completion Ownership&lt;/a&gt;, where merged plays the role of accepted and shipped plays the role of observed, and with &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;Validation Is a Loop, Not an Assertion&lt;/a&gt; on proving delivery rather than assuming it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opensource</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Log Printed Exactly What I Wanted. The Fix Was Still Wrong.</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Wed, 19 Aug 2026 15:31:14 +0000</pubDate>
      <link>https://dev.to/harrisonsec/the-log-printed-exactly-what-i-wanted-the-fix-was-still-wrong-41jl</link>
      <guid>https://dev.to/harrisonsec/the-log-printed-exactly-what-i-wanted-the-fix-was-still-wrong-41jl</guid>
      <description>&lt;p&gt;I spent two days on a bug where a screen-capture stream never came back after the display went to sleep. When I finally had a patch I believed in, I ran it, put the machine's display to sleep, woke it, and watched the log print the exact line I had been chasing for two days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Display [1] woke from sleep, reinitializing capture
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the line. Detection working, capture reinitializing, precisely as designed. I had the confirmation I wanted. About ten seconds later the entire host process died.&lt;/p&gt;

&lt;p&gt;The detection was right. The log line was true. And the patch it belonged to was worse than the bug it fixed, because the original bug only failed to recover, while my version took the whole process down with it. The message I had been hoping to see is exactly what nearly convinced me the thing was ready to ship.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every piece of evidence supports exactly one claim. Trouble starts when you borrow it for a bigger claim than it can carry. A log line proves the line ran, not that the fix is right. Before you trust a signal, name the claim it actually supports, and check that it is the claim you are making.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This belongs to the same first-principles line as &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;a wrong ruler is worse than no ruler&lt;/a&gt; and &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;validation is a loop, not an assertion&lt;/a&gt;. A wrong ruler is about a measurement that reports the wrong number. Validation-is-a-loop is about proving an outcome rather than asserting it. This piece is about the layer underneath both: the raw signals you reason from, and the quiet ways a true signal gets promoted into a false conclusion. It came out of the same investigation as &lt;a href="https://harrisonsec.com/blog/nat64-vpn-raw-socket-address-substitution/" rel="noopener noreferrer"&gt;the NAT64 address bug&lt;/a&gt;, and it happened four separate times in one day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim a signal actually supports
&lt;/h2&gt;

&lt;p&gt;The frame that would have saved me each time is small. Every signal, a log line, a captured packet, a returned value, an assistant's answer, is true about one specific thing. It supports one claim. The failure is never that the signal lied. It is that I read one claim off it and used a larger one.&lt;/p&gt;

&lt;p&gt;The log line above supports the claim &lt;em&gt;the code detected the wake and started reinitializing.&lt;/em&gt; It does not support &lt;em&gt;the reinitialization succeeded,&lt;/em&gt; and it certainly does not support &lt;em&gt;the process is healthy.&lt;/em&gt; I used it for the third claim. Here it is written out, and then the other three from that day, because together they map the distinct ways a true signal turns into a wrong conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case one: a signal that is present but not diagnostic
&lt;/h2&gt;

&lt;p&gt;The wake line printed and the process died ten seconds later because the patch detected the event correctly and then tore down the capture in the wrong order. It released the video outputs while the capture session still held them, and the runtime hit a fatal over-release a moment later. The correct teardown, the one the normal stop path already used, removes the outputs from the session and its bookkeeping first, so that by the time anything is released nothing else is still pointing at it. My version skipped that and went straight to stopping the session, which felt equivalent and was not.&lt;/p&gt;

&lt;p&gt;The point is not the ordering bug. The point is that the log line and the crash were both true, and the log line was upstream of the crash in time, so it printed before anything went wrong and told me nothing about what came after. A signal that fires at the start of an operation cannot testify about the end of it. It was present, it was accurate, and it was not diagnostic of the thing I was using it to judge. Had I closed the loop the way &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;validation is a loop&lt;/a&gt; argues, checking that the process was still alive and streaming ten seconds later rather than trusting the announcement that it had started, the patch would never have looked ready. This is also the &lt;a href="https://harrisonsec.com/blog/tool-calls-need-completion-ownership/" rel="noopener noreferrer"&gt;completion-ownership gap&lt;/a&gt; in miniature: &lt;em&gt;woke from sleep, reinitializing&lt;/em&gt; is the accepted signal, and &lt;em&gt;the process is still up and delivering frames&lt;/em&gt; is the observed effect, and I reported the first as if it were the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case two: a signal that is correct but incomplete
&lt;/h2&gt;

&lt;p&gt;Earlier that day I needed to prove a test had actually run over cellular rather than accidentally over Wi-Fi. The server logged the connecting client at an address on my VPN, I saw it, and I took it as proof the phone was on the cellular path. The address was genuinely the client's address. It was completely correct. It also proved nothing, because that same VPN address is what the phone presents on Wi-Fi too. The signal was accurate and it was insufficient, and the gap between accurate and sufficient is exactly where the wrong inference lived.&lt;/p&gt;

&lt;p&gt;The evidence that would actually have supported the claim was different: the peer endpoint the VPN was using at that moment, which on cellular was a global address on the carrier's network and on Wi-Fi would have been a local one. That is the signal that distinguishes the two cases. The one I had could not, no matter how true it was. Reaching for a real but insufficient signal because it points the way you want is the same failure a &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;wrong ruler&lt;/a&gt; describes, one measurement standing in for another it only resembles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case three: a fix that is plausible but wrongly scoped
&lt;/h2&gt;

&lt;p&gt;When I first understood the address bug, I had a fix ready that was clean and obvious. Force the resolver to keep IP literals verbatim, so the address I typed is the address that gets dialed. It reads well, it is one flag, and it would have quietly broken every user who legitimately needs address synthesis to reach a public host from an IPv6-only network. The plausibility of the fix was doing the work that evidence of its correctness should have been doing.&lt;/p&gt;

&lt;p&gt;The fix that actually shipped upstream scopes the fallback narrowly, keeping the raw literal only for private and carrier-grade-NAT ranges, because those addresses do not exist on the public internet and never needed synthesizing, while leaving synthesis intact for everyone else. The difference between the two is not cleverness. It is that the narrow one asked &lt;em&gt;who does this break&lt;/em&gt; and the broad one only asked &lt;em&gt;does this fix me.&lt;/em&gt; A solution feeling tidy and complete is not evidence that it is correct, and the tidier it feels the more it invites you to skip the question of who falls outside its scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case four: no signal at all where one was expected
&lt;/h2&gt;

&lt;p&gt;The fourth one was the emptiest. A build configuration step hung for ten minutes and produced no output whatsoever. The log simply stopped, mid-sentence, on an ordinary line, with no error and no progress. It is tempting to read a long quiet stretch as slow work, and I started to.&lt;/p&gt;

&lt;p&gt;What had happened was that the build's search for a Git executable matched a graphical application on the machine that happened to be named like Git, launched it, asked it for its version, and waited forever for a GUI app to answer a question it would never answer. There was no error because nothing had failed. A process was simply blocked on something that would never return, and a blocked process emits nothing. The absence of output was the signal, and I only read it by looking at what the process was actually doing, its children, one of which was a window-less app sitting open, rather than waiting for a log line that was never going to come. Silence past the expected time is not the absence of information. It is information, and it usually means look at the state directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways a true signal lies
&lt;/h2&gt;

&lt;p&gt;Lined up, the four cases are a small taxonomy of how evidence misleads without ever being false:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Present but not diagnostic.&lt;/strong&gt; The wake line was true and fired before the failure, so it could not speak to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct but incomplete.&lt;/strong&gt; The client address was really the client's and still could not distinguish the two networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plausible but wrongly scoped.&lt;/strong&gt; The verbatim-literal fix was clean and would have broken a class of users the narrow fix protects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absent where expected.&lt;/strong&gt; The silent hang produced no error because nothing failed, only blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is a fabrication. Every signal was real. Each one supported a claim, and in each case I attached it to a larger claim it could not hold. The discipline is not to distrust evidence. It is to know exactly what each piece proves and refuse to let it prove one inch more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assistant is another signal
&lt;/h2&gt;

&lt;p&gt;All four happened while I was pairing with an AI assistant, and it was genuinely fast and genuinely useful. It read both codebases in minutes. It decoded a synthesized address to its embedded IPv4 by hand. It was also the source of the wrongly scoped fix in case three, a fix that was fluent, confident, and worse than the one already merged upstream.&lt;/p&gt;

&lt;p&gt;The lesson people usually reach for here is that AI produces confident wrong answers. That is true, and it is tired, and it turns the observation into a complaint. The sharper version is that the assistant's output is one more signal, subject to the same single question as every other signal in this piece: what claim does this actually support? A fast, well-read, fluent answer is still just an answer, and fluency is not evidence of correctness any more than a hoped-for log line is. Building with these tools does not change the discipline. It raises the stakes on it, because the wrong signals now arrive faster and better argued. Naming the claim before trusting the signal is exactly what keeps a persuasive wrong answer from becoming a shipped one, which is the whole reason judgement matters more in this workflow rather than less.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was real, and what it proved
&lt;/h2&gt;

&lt;p&gt;In every one of these the evidence was real. The log line printed. The address appeared. The fix compiled and read cleanly. The build genuinely had no error. Not one of them was noise or fabrication, and every one of them was borrowed for a conclusion it could not support. That is the failure mode worth internalizing, because it survives more experience and better tools than the obvious kind. You get better at reading logs, and the misleading log line still reads as confirmation. You get faster with an assistant, and the plausible wrong fix still arrives plausible.&lt;/p&gt;

&lt;p&gt;The habit that holds up is boring and it is cheap. For each piece of evidence, before it becomes load-bearing, say out loud the one claim it actually supports, and then check that against the claim you are about to make. Most of the time they match. The day they do not is the day the log line you were hoping for talks you into shipping the thing that breaks.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This piece sits in the Generative Systems, First Principles line, alongside &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A Wrong Ruler Is Worse Than No Ruler&lt;/a&gt;, &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;Validation Is a Loop, Not an Assertion&lt;/a&gt;, and &lt;a href="https://harrisonsec.com/blog/tool-calls-need-completion-ownership/" rel="noopener noreferrer"&gt;Tool Calls Need Completion Ownership&lt;/a&gt;. The four cases here came out of the investigation in &lt;a href="https://harrisonsec.com/blog/nat64-vpn-raw-socket-address-substitution/" rel="noopener noreferrer"&gt;Your VPN Is Fine. iOS Swapped the Address.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harrisonsec.com/blog/evidence-that-isnt-evidence/" rel="noopener noreferrer"&gt;harrisonsec.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>ai</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Your VPN Is Fine. iOS Swapped the Address.</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 22:34:59 +0000</pubDate>
      <link>https://dev.to/harrisonsec/your-vpn-is-fine-ios-swapped-the-address-56nh</link>
      <guid>https://dev.to/harrisonsec/your-vpn-is-fine-ios-swapped-the-address-56nh</guid>
      <description>&lt;p&gt;Here is a failure that survives every reasonable thing you can check.&lt;/p&gt;

&lt;p&gt;One app, on an iPhone, on cellular data, talking to a machine on your own VPN. The app pairs. It fetches the host's info. It loads the list of things you can launch. Then the one connection that actually matters, the streaming session, hangs for ten seconds and times out. Switch the phone to Wi-Fi and everything works. Switch back to cellular and it dies again, in the same place, every time.&lt;/p&gt;

&lt;p&gt;So you check the obvious things, and they are all fine. The server's ports are bound and listening. The firewall is off. The VPN is up. From a browser on the same phone, over the same cellular connection, you can open the exact host and port the app is failing to reach. It loads instantly. You push a few hundred kilobytes through the tunnel in both directions and it arrives without a stall. Every network path you can test is clean. And one specific connection, in one specific app, still refuses to complete, with a timeout that names nothing.&lt;/p&gt;

&lt;p&gt;This is not a story about finding a bug. The bug was found and fixed upstream a year before I ran into it, and I will get to that. It is a story about a failure mode that is nearly impossible to recognize from the inside, and about the one observation that makes it obvious.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;On an IPv6-only network, &lt;code&gt;getaddrinfo()&lt;/code&gt; on an IPv4 literal does not hand you back that IPv4. It hands you a synthesized IPv6 address that the carrier will translate on the way out. If the literal was a VPN address, its synthesized form is outside the VPN's routes, so the packet leaves the tunnel and dies at the carrier's gateway. Nothing is misconfigured, and nothing logs an error. The bug lives in an address you were never shown.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The unit is unfamiliar, the shape is old
&lt;/h2&gt;

&lt;p&gt;This is a networking field note rather than a first-principles essay, but the reason it is worth writing is the same reason &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;a wrong ruler is worse than no ruler&lt;/a&gt;: the evidence you have is not the evidence you need, and it points confidently in the wrong direction. Every signal here reads as &lt;em&gt;the network is fine&lt;/em&gt; or &lt;em&gt;the connection timed out&lt;/em&gt;, and both are true and neither helps. The connection did time out. The network was fine. The address changed under you, silently, in one of the two code paths, and no instrument in your normal kit is pointed at that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom fingerprint
&lt;/h2&gt;

&lt;p&gt;Before any capture, the shape of the failure already rules out most of what you would guess. Write it down as a fingerprint, because recognizing it is the entire skill here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It fails on cellular and works on Wi-Fi.&lt;/li&gt;
&lt;li&gt;Inside a single app, one protocol works and another does not.&lt;/li&gt;
&lt;li&gt;The server is provably reachable at the same address and port from a different client on the same phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what I verified was not the problem, all on cellular, before I stopped guessing and started capturing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server ports bound, both address families&lt;/td&gt;
&lt;td&gt;listening on all interfaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS firewall and VPN shields&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same host and port in Safari, over cellular&lt;/td&gt;
&lt;td&gt;opens instantly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMP through the tunnel&lt;/td&gt;
&lt;td&gt;fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP both directions through the tunnel&lt;/td&gt;
&lt;td&gt;fine, a few hundred KB in a quarter second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The streaming handshake reaching the host&lt;/td&gt;
&lt;td&gt;never arrives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the tell. Not &lt;em&gt;arrives and is rejected&lt;/em&gt;. Not &lt;em&gt;arrives late&lt;/em&gt;. Never arrives. The server never logs the incoming connection at all, while HTTP requests from the same app keep landing normally throughout. One app is reaching the host on some connections and, on one connection, sending packets somewhere the host never sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one app splits into two destinations
&lt;/h2&gt;

&lt;p&gt;Everything turns on a detail that is invisible unless you already know to look for it: the two kinds of connection are built two different ways.&lt;/p&gt;

&lt;p&gt;The HTTP calls, the ones that pair and fetch info and load the launch list, go through the platform's high-level networking, &lt;code&gt;NSURLSession&lt;/code&gt; and &lt;code&gt;CFNetwork&lt;/code&gt;. You hand that stack an IPv4 literal and it hands the literal to the system, and the VPN routes it the way it routes everything else. It works.&lt;/p&gt;

&lt;p&gt;The streaming connection does not use that stack. It resolves the address itself, in portable C, with &lt;code&gt;getaddrinfo()&lt;/code&gt;. And on an IPv6-only network with NAT64/DNS64, &lt;code&gt;getaddrinfo()&lt;/code&gt; on an IPv4 literal does something most people have never had a reason to notice. It does not return the IPv4 you passed. It returns a &lt;em&gt;synthesized&lt;/em&gt; IPv6 address: the carrier's NAT64 prefix with your IPv4 embedded in the low bytes, so that IPv6-only clients can still reach IPv4-only hosts. That is a genuinely useful feature, and for a public IPv4 host it is exactly what you want.&lt;/p&gt;

&lt;p&gt;But your VPN address is not a public host. When the IPv4 literal is a VPN range address and it gets synthesized into a NAT64 IPv6 address, the result is no longer inside the VPN's routes. It matches the carrier's prefix, so it leaves the tunnel entirely, goes to the carrier's NAT64 gateway, and the gateway tries to find a CGNAT address on the public internet that does not exist there. The SYN retransmits into silence until the ten second timer fires.&lt;/p&gt;

&lt;p&gt;Same app. Same host. Same second. Two code paths. Two destinations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Same app, one host: an IPv4 VPN literal
   |
   |-- HTTP (pair, info, launch)
   |       -&amp;gt; CFNetwork / NSURLSession passes the literal through
   |       -&amp;gt; VPN routes it inside the tunnel
   |       -&amp;gt; reaches the host                                  [OK]
   |
   \-- Streaming (RTSP)
           -&amp;gt; getaddrinfo() on the IPv4 literal
           -&amp;gt; iOS synthesizes a NAT64 IPv6
           -&amp;gt; synthesized address is outside the VPN routes
           -&amp;gt; leaves the tunnel, dies at the carrier gateway    [FAIL]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part readers will not have seen before, and it is why the failure is so disorienting. The two connections do not disagree about the address. They were both given the same address. They disagree about what resolving it means, and only one of them resolves it at a layer where the carrier can rewrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a synthesized address by eye
&lt;/h2&gt;

&lt;p&gt;Once you have a capture, you can confirm all of this without any tooling beyond arithmetic, because NAT64 does not hide the IPv4. It embeds it. The synthesized address is the carrier's prefix followed by the original IPv4 written straight into the low 32 bits, one byte per octet, in hex.&lt;/p&gt;

&lt;p&gt;Take the well-known NAT64 prefix &lt;code&gt;64:ff9b::/96&lt;/code&gt; and a documentation address &lt;code&gt;192.0.2.33&lt;/code&gt;. The synthesized form is &lt;code&gt;64:ff9b::c000:221&lt;/code&gt;, and those trailing hex digits are the address itself: &lt;code&gt;c0 00 02 21&lt;/code&gt; is &lt;code&gt;192 0 2 33&lt;/code&gt;. Any address you see that ends in the hex of a familiar IPv4 of yours, sitting inside a &lt;code&gt;/96&lt;/code&gt; prefix that is not one you configured, is a synthesized address. In my capture the streaming SYN went to a prefix I had never set, and its low four bytes were my host's VPN IPv4 in hex. The client never dialed the address I gave it.&lt;/p&gt;

&lt;p&gt;Here is the masked shape of what the capture showed, host address written as &lt;code&gt;100.a.b.c&lt;/code&gt;, carrier prefix as &lt;code&gt;&amp;lt;nat64&amp;gt;&lt;/code&gt;, phone as &lt;code&gt;&amp;lt;phone&amp;gt;&lt;/code&gt;. Same capture, same host, same few seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Streaming (RTSP): synthesized IPv6, retransmits into silence, then ETIMEDOUT
IP6 &amp;lt;phone&amp;gt; &amp;gt; &amp;lt;nat64&amp;gt;::&amp;lt;hex(100.a.b.c)&amp;gt;.48010: Flags [S]      # SYN
IP6 &amp;lt;phone&amp;gt; &amp;gt; &amp;lt;nat64&amp;gt;::&amp;lt;hex(100.a.b.c)&amp;gt;.48010: Flags [S]      # +1s, same seq
IP6 &amp;lt;phone&amp;gt; &amp;gt; &amp;lt;nat64&amp;gt;::&amp;lt;hex(100.a.b.c)&amp;gt;.48010: Flags [S]      # +2s, same seq
... 7 retransmits, no reply ...

# HTTPS to the SAME host, same capture: plain IPv4, inside the tunnel, works
IP  &amp;lt;phone&amp;gt; &amp;gt; 100.a.b.c.47984: Flags [S]  -&amp;gt;  SYN-ACK, session proceeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I posted the full masked capture and analysis to the upstream issue this failure belongs to, &lt;a href="https://github.com/moonlight-stream/moonlight-ios/issues/669#issuecomment-5304346241" rel="noopener noreferrer"&gt;moonlight-ios #669&lt;/a&gt;, where it had sat open since 2025 with plenty of &lt;em&gt;me too&lt;/em&gt; and no root cause. The addresses there are masked the same way, structure kept verbatim, values replaced. Never publish the raw capture. Thirty seconds of a phone's traffic is thirty seconds of everything else the phone was doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capture recipe
&lt;/h2&gt;

&lt;p&gt;You cannot run a normal packet capture on an iPhone, but you can mirror its traffic to a Mac over USB with a Remote Virtual Interface, which ships with Xcode. Two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rvictl &lt;span class="nt"&gt;-s&lt;/span&gt; &amp;lt;device-UDID&amp;gt;          &lt;span class="c"&gt;# creates rvi0, mirroring the phone&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; rvi0          &lt;span class="c"&gt;# capture; -n so it does not rewrite what you are trying to read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap worth stating, because it cost me time. Capture full frames. If you truncate with a small snapshot length, the PKTAP encapsulation that carries these packets does not survive the truncation and your capture becomes unreadable. Let it capture whole packets and filter later.&lt;/p&gt;

&lt;p&gt;And validate the instrument before you trust its silence. When I first saw &lt;em&gt;no packets at all&lt;/em&gt; on the failing path, that was almost a conclusion, and it would have been the wrong one, because a capture on the wrong interface also shows no packets. The rule is the same one that runs through everything I write about verification: &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;confirm the check can see a case it should see&lt;/a&gt; before you believe it about a case it should not. Capture a working connection first. If the tool shows you the packets you know are there, then its silence on the broken path means something.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the error code, because it lies too
&lt;/h2&gt;

&lt;p&gt;The streaming failure surfaced as error 60, &lt;code&gt;ETIMEDOUT&lt;/code&gt;. It is tempting to read that as &lt;em&gt;connected, but got no reply&lt;/em&gt;, and I did read it that way for a while, and it sent me looking in the wrong place. On Apple platforms &lt;code&gt;ETIMEDOUT&lt;/code&gt; is produced by two entirely different paths: the receive timeout after a connection is established, and the connect timeout itself, because a TCP connect that never completes also ends in &lt;code&gt;ETIMEDOUT&lt;/code&gt;. The same number, 60, means both &lt;em&gt;never connected&lt;/em&gt; and &lt;em&gt;connected and heard nothing&lt;/em&gt;. You cannot tell them apart from the code. Here it was the first one, and the code implied the second. It is a small, sharp example of a signal that is present, precise, and not diagnostic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the general escape
&lt;/h2&gt;

&lt;p&gt;The direct fix is to hand the client an address it cannot synthesize. On an IPv6-only network the host's own IPv6 literal is passed through untouched, so pointing the client at the IPv6 address keeps the traffic inside the tunnel where it belongs. In this particular app that meant two things together: put the server in dual-stack mode so it actually listens on IPv6, and delete every existing IPv4 host entry on the client before adding the IPv6 one, because as long as a synthesizable IPv4 entry is still around the client will fall back to it and get synthesized again. I watched exactly that happen, an IPv6 entry added next to the old IPv4 one, capture still showing the synthesized destination.&lt;/p&gt;

&lt;p&gt;The library-level fix, the one that belongs in the client rather than in your config, is more interesting because of what it gets right. The naive version is to notice the address is an IPv4 literal and force the resolver to keep it verbatim. That works for your VPN and quietly breaks every legitimate public IPv4 host that genuinely needs synthesizing to be reachable from an IPv6-only network. The correct version scopes the fallback: keep the raw IPv4 only when the literal is in a private or CGNAT range, because those addresses do not exist on the public internet and there was never any point synthesizing them. That is what &lt;code&gt;moonlight-common-c&lt;/code&gt; shipped in July 2025, and it is a small lesson worth keeping: the fix that looks more thorough, the one that turns the feature off, is often the one that drops an entire class of users on the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually scarce
&lt;/h2&gt;

&lt;p&gt;Nothing in this failure was misconfigured. The VPN was routing correctly. The server was listening correctly. The app was, in a sense, behaving correctly on both paths. Every log said either &lt;em&gt;fine&lt;/em&gt; or &lt;em&gt;timed out&lt;/em&gt;, and both were honest. The defect was a silent address substitution that happened in one of two code paths, at a layer none of the normal instruments were pointed at, and it left no error anywhere because from each layer's own point of view nothing had gone wrong.&lt;/p&gt;

&lt;p&gt;The value here was never the fix. The fix existed, upstream, a year before I hit the wall, in a commit whose message describes this exact situation. What was scarce was recognition: seeing the fingerprint, distrusting the timeout, distrusting the error code, and getting an eye on the one address the software never printed. If you self-host anything behind a VPN and reach it from a phone on cellular, this failure is waiting for you, and now it has a shape you can name.&lt;/p&gt;

&lt;p&gt;There is a second, stranger half to this story. That upstream fix has existed for over a year and still has not reached the users hitting this bug, because of a single unbumped pointer in a dependency graph. That one is about delivery rather than diagnosis, and I will take it up separately.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a Network Field Note. It shares its spine with the correctness pieces on this site: &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A Wrong Ruler Is Worse Than No Ruler&lt;/a&gt; on trusting the wrong signal, and &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;Validation Is a Loop, Not an Assertion&lt;/a&gt; on proving your instrument can see before you believe its silence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harrisonsec.com/blog/nat64-vpn-raw-socket-address-substitution/" rel="noopener noreferrer"&gt;harrisonsec.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>ios</category>
      <category>debugging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Tool Calls Need Completion Ownership: Why Your Agent's 200 OK Lies</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Thu, 13 Aug 2026 16:45:35 +0000</pubDate>
      <link>https://dev.to/harrisonsec/tool-calls-need-completion-ownership-why-your-agents-200-ok-lies-na7</link>
      <guid>https://dev.to/harrisonsec/tool-calls-need-completion-ownership-why-your-agents-200-ok-lies-na7</guid>
      <description>&lt;p&gt;An agent is asked to send a payment confirmation email. It calls the &lt;code&gt;send_email&lt;/code&gt; tool. The tool returns &lt;code&gt;{"status": "queued", "message_id": "msg_abc123"}&lt;/code&gt;. The agent writes back to the user: &lt;em&gt;Done. Confirmation email sent.&lt;/em&gt; Two hours later a support ticket arrives. The customer never got the email. The provider's queue had dropped the message during a regional failover, and no one on the path between the agent and the recipient's inbox ever held ownership of the claim &lt;em&gt;this email reached the customer.&lt;/em&gt; Every layer received a token, forwarded it, and declared its own part finished.&lt;/p&gt;

&lt;p&gt;The instinct in the incident review is to blame the model. It reported something false with total confidence, so the model must have hallucinated. It did not. The model read a success signal and faithfully relayed it. The signal was a lie before the model ever saw it, and it was a lie for a reason that has nothing to do with language models.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A tool call's 200 means the request was accepted, not that the effect happened. Put completion ownership in the wrapper: verify the side effect before the word success ever reaches the model. The model cannot infer completion from a status code, and it will report exactly the confidence the token implies.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This sits in the same first-principles line as &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;validation is a loop, not an assertion&lt;/a&gt; and &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;determinism where you can, judgement where you must&lt;/a&gt;. Validation-is-a-loop argues that in a generative system you have to check the agent's work rather than assume it. This piece is about a narrower and sneakier place the check goes missing: not the model's reasoning, but the tool boundary underneath it, where a status code gets mistaken for a fact. It is also the direct descendant of an old distributed-systems question I wrote about in &lt;a href="https://harrisonsec.com/blog/rpc-vs-nats-who-owns-completion/" rel="noopener noreferrer"&gt;RPC vs NATS, who owns completion&lt;/a&gt;. The agent stack rediscovered that question and, for the most part, got it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 200 that means accepted, not done
&lt;/h2&gt;

&lt;p&gt;Trace the token upward through a typical stack and you find three separate places where an honest but incomplete signal gets promoted into a false one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tool API layer.&lt;/strong&gt; The tool returns 200 because the request was accepted, not because the effect was observed. Email providers return 200 on enqueue. Async job endpoints return 202 with a job id. Eventual-consistency writes return 200 the moment the write is durable on one node, before it has propagated anywhere a subsequent read would find it. In every one of these, 200 is a true statement about &lt;em&gt;receipt&lt;/em&gt; and says nothing about &lt;em&gt;outcome.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent framework layer.&lt;/strong&gt; The framework sees a 2xx, marks the tool call as succeeded, and appends a &lt;code&gt;success&lt;/code&gt; entry to the conversation. This is the layer that does the real damage, because it collapses two genuinely different states, request accepted and effect observed, into one word. The framework had the status code and threw away everything about what it meant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model layer.&lt;/strong&gt; The model reads &lt;code&gt;success&lt;/code&gt; in the tool result and generates &lt;em&gt;Done. Email sent,&lt;/em&gt; with high confidence. The confidence is not a defect. It is the correct response to the token it was given. Ask a person to relay a message stamped SUCCESS and they will relay it as success too. The model is the last honest link in a chain that lied to it three steps earlier.&lt;/p&gt;

&lt;p&gt;The customer, at the end of all this, gets a confident report that contradicts observable reality. And the postmortem points at the one component that behaved correctly given its inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where completion ownership went
&lt;/h2&gt;

&lt;p&gt;The underlying problem is older than agents, older than HTTP. Any time an operation crosses a boundary, someone has to own the answer to &lt;em&gt;did it actually happen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;RPC's classic wound is the caller who gets a network error partway through a call. Did the operation run or not? A network error is not a no. It is an &lt;em&gt;unknown.&lt;/em&gt; The operation may have committed on the server and had its acknowledgement lost on the way back. Without an idempotency key to retry safely and a read-after-write check to confirm the effect, the caller genuinely cannot tell, and any recovery it attempts is a guess.&lt;/p&gt;

&lt;p&gt;The way out, in every reliable system, is to name an owner. Either the caller polls until it sees the effect with its own eyes, or the receiver commits and only then reports back. As I argued in the &lt;a href="https://harrisonsec.com/blog/rpc-vs-nats-who-owns-completion/" rel="noopener noreferrer"&gt;RPC vs NATS piece&lt;/a&gt;, fire-and-forget messaging pushes ownership onto the caller, and request-response pushes it onto the receiver. Neither placement is wrong. What is always wrong is &lt;em&gt;ambiguity&lt;/em&gt; about which layer holds it, because ambiguity means the answer is nobody, and nobody is exactly what produces the phantom confirmation email.&lt;/p&gt;

&lt;p&gt;AI tool calls inherited this problem and, in the rush to wire models to real actions, mostly skipped the part where you decide who owns completion. The wrapper trusts the API's 200. The API trusts its dispatcher. The dispatcher trusts the worker. The worker logs success on enqueue. The token travels all the way up to the model and out to the user, and at no point did any layer commit to having observed the effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declare the mode in the wrapper
&lt;/h2&gt;

&lt;p&gt;The fix starts by refusing to let a status code stand in for an outcome. Every tool wrapper has to declare which of three modes it operates in, and that declaration lives in the wrapper, not in the model's head.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Returns when&lt;/th&gt;
&lt;th&gt;Caller responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;committed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The effect has been observed&lt;/td&gt;
&lt;td&gt;May report success directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accepted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The request was enqueued, effect not yet observed&lt;/td&gt;
&lt;td&gt;Must verify the effect before reporting success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;optimistic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Best-effort send, no guarantee available&lt;/td&gt;
&lt;td&gt;Must surface uncertainty to the user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a wrapper-level discipline for a concrete reason: the model cannot derive the mode from a 200. Two tools can return byte-identical success payloads while one has durably applied its effect and the other has merely queued it. The distinction exists only in knowledge the wrapper has and the status code does not carry. If the wrapper does not encode the mode, the information is gone by the time the model sees the result, and no amount of prompting recovers it. This is the &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;technique boundary&lt;/a&gt; in miniature: the deterministic layer knows something the model cannot infer, so the deterministic layer has to state it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The closed loop for accepted-mode tools
&lt;/h2&gt;

&lt;p&gt;For anything in &lt;code&gt;accepted&lt;/code&gt; mode, success is not a value the tool returns. It is a state a verifier confirms. The wrapper returns a handle, and a verification step stands between the tool and the model's next turn.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The wrapper returns &lt;code&gt;{"status": "accepted", "polling_token": "..."}&lt;/code&gt;. It does not return &lt;code&gt;success&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A verifier step polls the token until it reaches a terminal state, delivered or failed, reading the actual effect rather than re-reading the queue that accepted it.&lt;/li&gt;
&lt;li&gt;Only the terminal state is surfaced to the model.&lt;/li&gt;
&lt;li&gt;If polling exhausts its budget, the wrapper surfaces &lt;code&gt;uncertain&lt;/code&gt;, the operation may or may not have completed, and explicitly not &lt;code&gt;success&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That verifier is middleware sitting between the tool and the next model turn, the same shape as the loop in &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;validation is a loop, not an assertion&lt;/a&gt;: the agent acts, the system verifies, scores the result, and routes on it. And the verification has to observe the effect itself, not a proxy for it. Re-reading the queue that already said &lt;em&gt;accepted&lt;/em&gt; will happily confirm &lt;em&gt;accepted&lt;/em&gt; forever. That is a &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;wrong ruler&lt;/a&gt;: a check that looks like verification but measures the wrong thing, which is worse than no check because it manufactures false confidence. A read-after-write worthy of the name reads the recipient's mailbox state, the inserted row, the written file, not the acknowledgement that a request to produce them was received.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent action
   |
   v
Tool wrapper
   |
   |-- committed --&amp;gt; Effect observed --------------&amp;gt; Model context: "success"
   |
   |-- accepted --&amp;gt; Verifier (poll the real effect)
                        |-- terminal: delivered ---&amp;gt; Model context: "success"
                        |-- terminal: failed ------&amp;gt; Model context: "failed"
                        |-- budget exhausted ------&amp;gt; Model context: "uncertain"
                        \-- not yet terminal ------&amp;gt; keep polling (loop back)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every path in that graph ends at an honest token. The only way the model reports success is if some layer below it actually watched the effect land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five questions for every tool in the box
&lt;/h2&gt;

&lt;p&gt;This is ordinary reliability hygiene, applied one tool at a time. For each tool your agent can call, you should be able to answer yes to all five:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the return value distinguish &lt;em&gt;queued&lt;/em&gt; from &lt;em&gt;applied&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;If the tool is asynchronous, is there a polling token or a callback to confirm the terminal state?&lt;/li&gt;
&lt;li&gt;Does the wrapper enforce verification before &lt;code&gt;success&lt;/code&gt; can reach the model?&lt;/li&gt;
&lt;li&gt;On verification timeout, does the wrapper surface &lt;em&gt;uncertain&lt;/em&gt;, rather than defaulting to &lt;em&gt;success&lt;/em&gt; or &lt;em&gt;failure&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;Is there a retry budget owned in one place, so a burst of retries cannot compound the ambiguity?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last question is where this connects to cost as well as correctness. Uncoordinated retries stacked at three layers are exactly the &lt;a href="https://harrisonsec.com/blog/your-ai-bill-is-a-distributed-systems-problem/" rel="noopener noreferrer"&gt;retry storm&lt;/a&gt; that inflates an AI bill, and they are also a completion-ownership failure: three layers each hoping the operation happened, none of them owning the answer. One retry budget in one place fixes both faces of the same bug.&lt;/p&gt;

&lt;p&gt;Any tool that fails one of these five carries completion-ownership debt, and that debt is paid in user-visible incorrectness, the agent asserting things that did not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is 2PC versus eventual consistency, wearing a tool schema
&lt;/h2&gt;

&lt;p&gt;For anyone who has built distributed systems, the whole thing has a familiar shape. A two-phase commit says &lt;em&gt;I hold ownership and will not report done until the effect is durable.&lt;/em&gt; Eventual consistency says &lt;em&gt;I forward my part and trust the next layer to converge.&lt;/em&gt; Both are legitimate designs. The trouble is that most agent tool stacks are built like eventual-consistency systems but &lt;em&gt;report&lt;/em&gt; like two-phase commits: they emit a crisp, immediate &lt;em&gt;success&lt;/em&gt; for an effect that is still only propagating, or still only queued, or already quietly dropped.&lt;/p&gt;

&lt;p&gt;The fix is not to force every tool into two-phase commit. Plenty of effects are genuinely eventual, and that is fine. The fix is honesty in the report. If the system is eventual, the token that reaches the model has to say so, &lt;code&gt;accepted&lt;/code&gt; and then a verified terminal state, never a premature &lt;code&gt;success&lt;/code&gt;. If you have shipped idempotency keys and read-after-write checks before, you already have every piece of this. The polling token is a request UUID with a different label, and the verifier is a read-after-write check you already know how to write.&lt;/p&gt;

&lt;p&gt;The bill for skipping it does not arrive as a stack trace. It arrives as a support ticket, hours later, from a customer the agent told with total confidence that something was done. Read the completion one layer earlier, in the wrapper, where you can still tell &lt;em&gt;accepted&lt;/em&gt; from &lt;em&gt;observed,&lt;/em&gt; and the model stops lying, because for the first time nothing below it is lying to the model.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This piece sits in the Generative Systems, First Principles line. Its correctness siblings: &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;Validation Is a Loop, Not an Assertion&lt;/a&gt;, &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A Wrong Ruler Is Worse Than No Ruler&lt;/a&gt;, and &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;Determinism Where You Can, Judgement Where You Must&lt;/a&gt;. The distributed-systems ancestor: &lt;a href="https://harrisonsec.com/blog/rpc-vs-nats-who-owns-completion/" rel="noopener noreferrer"&gt;RPC vs NATS, Who Owns Completion&lt;/a&gt;. The cost companion: &lt;a href="https://harrisonsec.com/blog/your-ai-bill-is-a-distributed-systems-problem/" rel="noopener noreferrer"&gt;Your AI Bill Is a Distributed Systems Problem&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harrisonsec.com/blog/tool-calls-need-completion-ownership/" rel="noopener noreferrer"&gt;harrisonsec.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your AI Bill Is a Distributed Systems Problem, Not a Model-Pricing Problem</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:01:31 +0000</pubDate>
      <link>https://dev.to/harrisonsec/your-ai-bill-is-a-distributed-systems-problem-not-a-model-pricing-problem-2h82</link>
      <guid>https://dev.to/harrisonsec/your-ai-bill-is-a-distributed-systems-problem-not-a-model-pricing-problem-2h82</guid>
      <description>&lt;p&gt;A team I was helping watched their model bill jump to several times its usual size in a single month. The token meter had not predicted it. The first question in the room was the one almost everyone asks: &lt;em&gt;did the provider raise prices, or should we move to a cheaper model?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both were the wrong question, and they were wrong in an instructive way. The same engineers, if their AWS egress bill had tripled, would never have opened with &lt;em&gt;did AWS raise prices.&lt;/em&gt; They would have asked &lt;em&gt;what is calling more,&lt;/em&gt; pulled up the request graph, and started looking for the amplification. The instinct is correct and automatic when the unit is HTTP calls. It deserts people the moment the unit becomes tokens.&lt;/p&gt;

&lt;p&gt;That gap is the whole subject of this piece.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A surprising AI bill is a symptom. The disease is almost always a distributed systems bug, a retry storm, a hidden fanout, a cache that stopped hitting, a conversation that grows without bound, that you would recognize instantly if it were denominated in network calls instead of tokens. Debug the call graph, not the model price.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This sits in the same first-principles line as &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;determinism where you can, judgement where you must&lt;/a&gt; and &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;validation is a loop, not an assertion&lt;/a&gt;. Where those are about correctness, this one is about cost, and the move is the same: take a problem that looks new because it wears an AI costume, and recognize the old shape underneath. On the architecture side it pairs with &lt;a href="https://harrisonsec.com/blog/observability-billing-t-architecture-ai-api-calls/" rel="noopener noreferrer"&gt;Observability and Billing for AI API Calls&lt;/a&gt;, which is about how to build the telemetry. This piece is about how to read it when the number jumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic flip
&lt;/h2&gt;

&lt;p&gt;When a backend engineer's egress bill spikes, the reflex is to ask what changed in the call graph. Nobody's first move is to email the vendor about pricing. When an AI engineer's model bill spikes, the reflex too often inverts: the first suspects are the price sheet and the model choice, and the call graph is the last thing anyone looks at.&lt;/p&gt;

&lt;p&gt;These are the same question wearing different clothes. &lt;em&gt;What is driving the spend, and is the driver behaving the way it should?&lt;/em&gt; We get it right for HTTP because we have years of muscle memory in that unit. We get it wrong for tokens because the unit is unfamiliar and, crucially, because the bill arrives pre-aggregated into a single dollar figure that hides the graph that produced it. The dollar figure is the least useful view of the problem, and it is usually the only view people start from.&lt;/p&gt;

&lt;p&gt;So before anything else, refuse the pricing question. Assume the spend is a downstream-service spend that misbehaved, and go find the misbehavior. It will almost always be one of five shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five failure modes, denominated in tokens
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Retry storms.&lt;/strong&gt; A transient provider error triggers a retry at the SDK layer, and again at the agent layer, and again at the workflow orchestrator, because all three were written to be resilient and none of them knew about the others. One user request becomes several billed calls. This is the exact retry storm you have debugged on a degraded downstream HTTP service, where nested retry policies multiply instead of coordinate. The fix is the same: one retry budget, owned in one place, not three hopeful ones stacked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fanout amplification.&lt;/strong&gt; An agent spawns several parallel tool calls, and each tool, somewhere inside its implementation, makes its own model call to parse or summarize its result, and each of those results feeds back into the main conversation. One user request quietly becomes many model calls, and most of them are hidden inside tool code where no one thinks to look. This is request fanout amplification, the same reason a single page load can trigger forty backend calls when each component fetches for itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cache misses where caches should hit.&lt;/strong&gt; The provider supports caching a stable prompt prefix, and you are not using it, so the same system prompt and the same long preamble get retokenized and rebilled on every single call. This is a CDN accidentally configured to pass every request through to origin. The content never changed, and you paid to recompute it anyway, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Unbounded conversation growth.&lt;/strong&gt; Conversation history is appended turn after turn with no compaction, so the tokens billed per turn grow with the length of the session, and the total cost of a session grows quadratically with its length. This is an unbounded buffer, the same class of leak that &lt;a href="https://harrisonsec.com/blog/channels-arent-message-passing/" rel="noopener noreferrer"&gt;treating a channel like message passing&lt;/a&gt; produces, except the buffer is a transcript and the leak shows up on an invoice instead of in a heap profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Wrong model for the job.&lt;/strong&gt; A frontier model is doing work a smaller model handles perfectly well, on every call, by default. This is running your ad-hoc analytical queries against the transactional cluster because it was the connection string you had, instead of against the warehouse built for them. It is also, in the language of the &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;technique boundary&lt;/a&gt;, using the most expensive technique where a cheaper one was sufficient, which is the cost face of the same mistake that piece describes for correctness.&lt;/p&gt;

&lt;p&gt;Four of these five are amplification bugs: something is producing more calls, or more tokens per call, than the work requires. The fifth is a tiering mistake. None of them is a price change, and none of them is fixed by switching models, which is exactly why switching models first tends to disappoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forensics, in order
&lt;/h2&gt;

&lt;p&gt;The diagnosis runs in the same order every time, and each step reads a distributed systems metric off the call graph, not a number off the token meter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Calls per user request
   near your workflow's theoretical minimum? -&amp;gt; healthy
   several times the minimum?                -&amp;gt; retry storm or hidden fanout (modes 1, 2)

2. Tokens-per-call distribution
   stable week over week?                    -&amp;gt; healthy
   the tail is climbing?                     -&amp;gt; unbounded conversation or uncached prefix (modes 3, 4)

3. Model mix over time
   right tier per task type?                 -&amp;gt; healthy
   a routing change shifted traffic up?      -&amp;gt; wrong model / tiering (mode 5)

4. Provider reject rate
   negligible?                               -&amp;gt; healthy
   climbing?                                 -&amp;gt; the trigger that sets off mode 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters. Provider rejects sit at the bottom of the list but at the root of the causal chain, because a rise in transient rejects is what ignites a retry storm at the top of it. Read the graph top to bottom to localize the symptom, then bottom to top to find the cause.&lt;/p&gt;

&lt;p&gt;The point of laying it out this way is that none of it is novel. Your observability stack already knows how to compute calls per request, tail distributions, traffic mix, and error rates. You compute them for every other downstream service you depend on. The only new thing is pointing that same machinery at the model provider.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  User request
       |
       v
     Agent  --fanout xN--&amp;gt;  Tool calls (each hides a model call)
       |                         |
       | retries xR              | retries xR
       v                         v
              Provider  (cache layer)
                     |
                     v
                 The bill
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every multiplier on an edge in that graph is a place the bill can balloon while the price per token never moves. The invoice only shows you the node at the far right. The leak is always on an edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing is not cost forensics
&lt;/h2&gt;

&lt;p&gt;Here is the line that decides whether you can do any of the above. If your AI observability emits tokens multiplied by price for each call and stops there, you have billing. You know what you paid. You cannot see why, because the why lives in the edges of the call graph, and a per-call token count has thrown the graph away.&lt;/p&gt;

&lt;p&gt;Cost forensics needs the graph kept intact: calls attributed back to the originating user request, fanout factors per tool, cache hit rates per prompt prefix, retry distributions per layer, model mix over time. That is the same telemetry shape you already maintain for HTTP dependencies, and building it deliberately is the subject of the &lt;a href="https://harrisonsec.com/blog/observability-billing-t-architecture-ai-api-calls/" rel="noopener noreferrer"&gt;T-shaped architecture piece&lt;/a&gt;. The relevant point here is diagnostic: if that data does not exist, no one, however senior, can tell you where your bill is leaking, because the evidence was discarded at collection time. Add the graph, and an engineer who has never touched an LLM but is good at HTTP cost forensics can find the leak, because a token is just a byte with cognitive content, and a model provider is just a downstream service that bills by the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a model switch is actually the answer
&lt;/h2&gt;

&lt;p&gt;Not never. After you have ruled out retries, fanout, cache misses, and conversation growth, mode five is a real and common diagnosis, and moving a class of calls to a smaller model is the right fix. The discipline is only about order. Switch models before ruling out the amplification modes and you can land on a cheaper model that still has a retry storm sitting on top of it, which presents as the smaller model being mysteriously expensive too, and now you have two confusing bills instead of one. Rule out the graph problems first. Then a model switch is a measurement-backed decision rather than a hopeful guess, and it tends to hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old hygiene, new invoice
&lt;/h2&gt;

&lt;p&gt;If your team has someone with good instincts for HTTP retry budgets, request fanout, cache hit ratios, and payload-size distributions, that person can already debug your model bill. Everything transfers. Retry budgets are retry budgets. Fanout is fanout. An uncached prefix is an uncached prefix. The provider is one more downstream service that responds to the same hygiene you already apply to object storage, managed databases, and third-party APIs.&lt;/p&gt;

&lt;p&gt;The bill is the last place the bug appears and the most expensive place to read it, because by the time it shows up there it has already been paid. Read it one step earlier, in the call graph, where every one of these five modes is visible as a multiplier on an edge, and where you have known how to fix each of them for years. Your AI bill is not a new kind of problem. It is an old kind of problem with an unfamiliar unit, and the moment you convert the unit back, you already know what to do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This piece sits in the Generative Systems, First Principles line. The correctness companions: &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;Determinism Where You Can, Judgement Where You Must&lt;/a&gt; and &lt;a href="https://harrisonsec.com/blog/validation-is-a-loop-not-an-assertion/" rel="noopener noreferrer"&gt;Validation Is a Loop, Not an Assertion&lt;/a&gt;. The architecture companion: &lt;a href="https://harrisonsec.com/blog/observability-billing-t-architecture-ai-api-calls/" rel="noopener noreferrer"&gt;Observability and Billing for AI API Calls&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Validation Is a Loop, Not an Assertion: Why Your AI Agent Reports Success It Never Achieved</title>
      <dc:creator>Harrison Guo</dc:creator>
      <pubDate>Tue, 28 Jul 2026 16:27:16 +0000</pubDate>
      <link>https://dev.to/harrisonsec/validation-is-a-loop-not-an-assertion-why-your-ai-agent-reports-success-it-never-achieved-1d32</link>
      <guid>https://dev.to/harrisonsec/validation-is-a-loop-not-an-assertion-why-your-ai-agent-reports-success-it-never-achieved-1d32</guid>
      <description>&lt;p&gt;An agent I was reviewing was asked to rename a function across a repository. It ran for a while, made its edits, and returned a clean summary: &lt;em&gt;Done. Renamed &lt;code&gt;parseUser&lt;/code&gt; to &lt;code&gt;parseAccount&lt;/code&gt; everywhere.&lt;/em&gt; The summary was confident, well formatted, and wrong. Six of eight call sites were updated. Two were not, one buried in a string that built the name dynamically and one in a test helper the search had skipped.&lt;/p&gt;

&lt;p&gt;The agent did not lie. It returned what its model believed was true at the moment it returned. The problem is that nothing sat between &lt;em&gt;I believe I succeeded&lt;/em&gt; and &lt;em&gt;I report success&lt;/em&gt;. There was no step that walked back out into the world, looked at what had actually changed, and compared it to what the task asked for. The agent's confidence and the agent's correctness were two completely unrelated numbers, and only one of them made it into the summary.&lt;/p&gt;

&lt;p&gt;This is the single most common way I see production agents fail, and it is almost never a model problem. It is a validation problem, and specifically it is validation built in the wrong shape.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In a deterministic system, validation is an assertion: check once, pass or fail. In a system driven by non-deterministic model output, validation has to be a loop: observe the real outcome, score how close it is, and decide whether to accept, retry, route, or stop. The most common production agent failure is treating the second case like the first.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This piece sits alongside two companions. &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;Determinism where you can, judgement where you must&lt;/a&gt; is about deciding which technique owns each part of a system. &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A wrong ruler is worse than no ruler&lt;/a&gt; is about verifying the checks you trust before you let them hold authority. This piece is about the shape of the check itself, once you have decided the output is non-deterministic enough to need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an assertion assumes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;assert(result == expected)&lt;/code&gt; is one of the most useful lines in deterministic software, and it is useful because of a set of assumptions that are almost always true in that world. The operation you just ran is deterministic, so it either did the thing or it did not. The check is far cheaper than the operation, so running it once at the end costs nothing worth counting. And the answer is binary, because in a deterministic system there is no meaningful state between success and failure. You parsed the integer or you threw. You wrote the row or you got an error.&lt;/p&gt;

&lt;p&gt;Every one of those assumptions breaks the moment the operation is a model call.&lt;/p&gt;

&lt;p&gt;The operation is no longer deterministic, so &lt;em&gt;it did the thing&lt;/em&gt; is now a probability, not a fact. The check is no longer trivially cheaper than the operation, because verifying whether a model actually accomplished a fuzzy goal can be almost as hard as the goal. And the answer is no longer binary, because the whole texture of model failure is partial. The rename that got six of eight. The summary that captured four of the five key points. The extraction that found every date except the one written in words. Partial success is not an edge case in these systems. It is the main case, and an assertion has no vocabulary for it. &lt;code&gt;6 of 8&lt;/code&gt; and &lt;code&gt;8 of 8&lt;/code&gt; collapse to the same &lt;code&gt;false&lt;/code&gt;, which throws away exactly the information you needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the loop
&lt;/h2&gt;

&lt;p&gt;A loop keeps the information an assertion discards. It does not ask &lt;em&gt;did it pass.&lt;/em&gt; It asks &lt;em&gt;how close did it get, and what should happen because of that.&lt;/em&gt; Concretely it is four moves and a stopping rule.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate by observation.&lt;/strong&gt; After the action, look at the real, observable outcome, not the model's report of it. For the rename, that means searching the codebase for the old and new names and seeing which call sites actually changed. The validator's input is the world, not the transcript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score with partial credit.&lt;/strong&gt; Turn the observation into a number or a structured verdict that can express &lt;em&gt;most of the way there.&lt;/em&gt; Six of eight is &lt;code&gt;0.75&lt;/code&gt;, and that number carries the two misses that a boolean would have erased.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide against thresholds.&lt;/strong&gt; Is the score high enough to call the task done? Low enough to abandon and report failure honestly? Or in the band between, where more work might close the gap?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route the in-between case.&lt;/strong&gt; When the score is neither clearly done nor clearly hopeless, choose the next action deliberately. Retry only the two missed call sites. Ask the user whether the dynamic-string case should even be touched. Escalate to a stronger model. The router is where the loop earns its keep, because it is the part an assertion cannot do at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iterate to a terminal condition.&lt;/strong&gt; Repeat until one of three things is true: the score clears the accept threshold, a retry budget is exhausted, or a human steps in. The budget matters as much as the score. A loop with no budget does not converge, it hangs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Drawn out, the control flow is small but it is a cycle, and the cycle is the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        +---------------- route: retry / clarify / escalate ----------------+
        |                                                                   |
        v                                                                   |
  Agent action --&amp;gt; Validate --&amp;gt; Score --&amp;gt; Decide --+-- clears accept  --&amp;gt; Report success
                  (observe)   (partial)            |
                                                   +-- below abandon  --&amp;gt; Halt, surface partial progress
                                                   |
                                                   +-- in between ------&amp;gt; Route ----------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice where control lives. The model reasons inside this loop and it can even do the scoring when the judgement is subjective. But it does not own the loop. The decision of what counts as done, and what happens when the task is not done, stays with the orchestrator. This is the same boundary the &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;technique piece&lt;/a&gt; draws: the model may reason under the control, it may not direct the control itself. An agent that gets to declare its own task finished and exit on its own authority has no loop. It has an assertion it wrote for itself and then graded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring function is the hard half
&lt;/h2&gt;

&lt;p&gt;If you have designed retry-with-backoff for a flaky network call, you have already built most of this loop. Observe, decide, retry with a budget, give up gracefully. That machinery ports over almost unchanged. The part that does not port, the part that makes this genuinely a production AI problem rather than a distributed-systems problem you already solved, is the scoring function.&lt;/p&gt;

&lt;p&gt;For the rename, scoring is easy, because the outcome is observable by deterministic means. You can enumerate call sites and count. Whenever you can score by observation like that, do, because a count does not hallucinate. The hard cases are the ones where the goal is subjective: is this summary faithful, is this explanation coherent, does this answer satisfy the brief. There the score has to come from judgement, often a model judging another model's output, and now you have a new problem stacked on the first one.&lt;/p&gt;

&lt;p&gt;That scorer is itself a check, and a check that can be wrong. If you let a broken scorer drive the loop, you do not merely fail to catch errors. You manufacture confident wrong verdicts and feed them straight back into the agent's next action. A loop built on a bad ruler is worse than no loop, for exactly the reasons the &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;companion piece&lt;/a&gt; lays out: a wrong check with authority does not leave you uncertain, it leaves you confidently wrong, and here it does so in a cycle that compounds. So the discipline from that piece applies in full to the scorer: verify it at least as hard as the claims it will act on, score by comparison against anchors rather than emitting a bare number, and know the ceiling of human agreement before you trust a model to beat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two places the loop lives
&lt;/h2&gt;

&lt;p&gt;In a real agent the loop shows up at two scales, and mature systems run both.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;per-step, as middleware.&lt;/strong&gt; A validator runs after every tool call, before the model sees the result. What gets fed back into the next prompt is not the raw API response. It is the validated, scored, sometimes rewritten version of it. This is what stops a single bad step from silently poisoning everything downstream, because the model never gets to reason on top of an unchecked result. The loop here is tight and cheap and runs constantly.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;end-to-end, as a harness.&lt;/strong&gt; At the boundary of the whole task, you check the full resulting state against the intended outcome and treat the score as a circuit breaker. If the end-to-end score is below threshold, the agent does not report success. It halts and surfaces the partial progress honestly: here is what changed, here is what did not, here is where it stopped. This is the layer that would have caught the rename. Per-step middleware might have passed each edit individually while the aggregate still missed two sites, and only a check against the whole goal sees that gap.&lt;/p&gt;

&lt;p&gt;You want both because they catch different failures. Middleware catches the step that went wrong. The harness catches the task that came out wrong even though every step looked fine. Skip the middleware and errors compound before you notice. Skip the harness and you are back to an agent that reports success it never achieved, which is where we started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distributed-systems reframe
&lt;/h2&gt;

&lt;p&gt;If all of this feels familiar, it should. It is eventual consistency wearing different clothes.&lt;/p&gt;

&lt;p&gt;An assertion-style check on a freshly written row in a distributed store fails not because the write was wrong but because it has not propagated yet. The correct pattern was never a single assertion. It was a loop: poll until the read converges with the write, or until a timeout says stop waiting. Model output validation is the same pattern with cognitive uncertainty standing in for replication lag. The output is not yet known-good, so you observe, you score, you decide whether to wait for a better one or accept what you have, and you bound the whole thing with a budget so it terminates.&lt;/p&gt;

&lt;p&gt;Which is why teams from a distributed-systems background tend to get this right faster once it is named. You already know that in any system where the truth arrives late and imperfectly, a one-shot check is the wrong instrument. You built loops for that years ago. An LLM is just another source where the truth arrives late and imperfectly. The retry-with-backoff instinct is half the answer. The scoring function is the half that is new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where an assertion is still right
&lt;/h2&gt;

&lt;p&gt;The loop is not a universal upgrade, and reaching for it everywhere is its own mistake. The whole reason the loop exists is that the output is non-deterministic. Where the output is deterministic, an assertion is not just adequate, it is correct, and wrapping a deterministic step in a scoring loop adds cost and latency to buy nothing.&lt;/p&gt;

&lt;p&gt;The schema either validates or it does not. The parser either succeeds or it throws. The row count either matches or it does not. Those are assertions, and they should stay assertions, sitting at the boundaries of your system doing exactly the one-shot job they are good at. The &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;technique boundary&lt;/a&gt; is the tool for deciding which is which: put as much of the system as you can onto deterministic ground where a plain assertion holds, and reserve the loop for the parts that are genuinely non-deterministic and genuinely need judgement. An agent that scores and retries its way through a JSON schema check is making the opposite mistake to the one this piece is about, and it is just as wasteful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one honest question
&lt;/h2&gt;

&lt;p&gt;The next time an agent hands you a confident &lt;em&gt;done&lt;/em&gt;, ask it the question the loop asks and the assertion cannot: not &lt;em&gt;did it pass&lt;/em&gt;, but &lt;em&gt;how do you know, and what did you observe to know it.&lt;/em&gt; If the answer is that the model believed it, you have an assertion the model wrote for itself. If the answer is a real observation of the world, scored against the goal, with a decision that followed from the score, you have a loop. Only one of those two agents can be trusted to tell you when it failed, and it is the one that was built to look.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This piece extends two companions. The boundary it assumes: &lt;a href="https://harrisonsec.com/blog/the-technique-boundary/" rel="noopener noreferrer"&gt;Determinism Where You Can, Judgement Where You Must&lt;/a&gt;. The check-verification discipline its scorer depends on: &lt;a href="https://harrisonsec.com/blog/a-wrong-ruler-is-worse-than-no-ruler/" rel="noopener noreferrer"&gt;A Wrong Ruler Is Worse Than No Ruler&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
